From 7c291bb08028206c9443afa5648e71813eb1e9f9 Mon Sep 17 00:00:00 2001 From: eason liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:59:28 +0000 Subject: [PATCH 01/35] fix(webview-message-handler): enforce workspace containment for markdown-sourced openFile requests (#1762) * fix(webview-message-handler): enforce workspace containment for markdown-sourced openFile requests * fix(webview-message-handler): harden markdown containment against encoded and symlinked paths Address the automated review findings on the split PR: percent-decode tagged openFile paths to a fixed point at the containment boundary (openFile decodes after the check, so %2e%2e traversal would escape only after that later decode), and re-check containment on the real filesystem path so a workspace-internal symlink cannot resolve outside the workspace (fail closed; unresolvable targets reject). Tests now exercise the production isPathOutsideWorkspace against a mutable mock workspace (multi-root, no-folders cases) and a deterministic realpath model: encoded and double-encoded traversal rejected, legitimately encoded filenames still open, symlink escape rejected, file creation under a new directory allowed. * test(extension): pin the new containment mutants and fail-closed branches Mutation preflight on e98713aab found blocking survivors in the new code: - direct tests for decodeUntrustedPathToStable (stable/fixed-point/invalid% escape/bound) and isRealPathOutsideWorkspace (no-folders, in-workspace, creation ancestor, symlink escape, EACCES fail-closed, unrealizable folder) cover the previously NoCoverage fail-closed returns - untagged percent-encoded paths stay undecoded at the boundary (legacy openFile decode applies exactly once) - kills the fromMarkdown gate mutant - a lexically outside path whose symlinked ancestor resolves inside is still rejected by the lexical check - kills the defense-in-depth ordering mutant - Stryker directives for the equivalent/bound mutants with concrete reasons * test(extension): cover the hostile-bound reject branch and the root guard - handler: a tagged encoding that never stabilizes inside the fixed-point bound is rejected at the boundary (covers the defensive null branch) - helper: the ancestor walk reaches the root guard when nothing exists and containment fails closed - locale-bundles.spec.ts imports all 18 common bundles and pins the path_outside_workspace key in each (completeness + puts the bundles into the coverage report for changed-line coverage) * fix(extension): type the realpath spy parameter as PathLike @types/node declares promises.realpath(path: PathLike, ...); string | URL is not assignable (PathLike also admits Buffer) * test(extension): assert the exact rejection message for the hostile-bound case Assert cannotAccessPathError with the raw path instead of only that an error was shown (CodeRabbit re-review, assertion identity) * chore: re-trigger CodeRabbit review * fix(extension): fail closed on dangling symlinks in realpath containment A dangling symlink inside a workspace folder fails realpath with ENOENT without being a nonexistent path: the ancestor walk would treat it as missing and check the (in-workspace) ancestor instead, while a creation flow (mkdir -p) would follow the link and escape the workspace. ENOENT from realpath now lstat's the entry first: an existing symlink whose target cannot be resolved fails closed; a genuinely absent entry keeps walking to its deepest existing ancestor. Unexpected lstat errors also fail closed. Adds the regression tests: dangling symlink rejected (handler and helper), a symlinked workspace root realized to the real root, and the lstat EACCES fail-closed path * chore(extension): document the equivalent lstat-symlink mutant Stryker marks the ConditionalExpression true/false replacements on the dangling-symlink check unobservable: lstat succeeding after a realpath ENOENT can only be a dangling symlink, so the condition is always true at every reachable point. The false replacement is pinned by the dangling-symlink tests; the directive documents the equivalence. --------- Co-authored-by: Eason Liang --- .../webviewMessageHandler.openFile.spec.ts | 603 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 67 +- src/i18n/__tests__/locale-bundles.spec.ts | 60 ++ src/i18n/locales/ca/common.json | 1 + src/i18n/locales/de/common.json | 1 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/hi/common.json | 1 + src/i18n/locales/id/common.json | 1 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 1 + src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pt-BR/common.json | 1 + src/i18n/locales/ru/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/vi/common.json | 1 + src/i18n/locales/zh-CN/common.json | 1 + src/i18n/locales/zh-TW/common.json | 1 + src/utils/pathUtils.ts | 120 ++++ 22 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts create mode 100644 src/i18n/__tests__/locale-bundles.spec.ts diff --git a/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts new file mode 100644 index 0000000000..d793737c20 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts @@ -0,0 +1,603 @@ +// npx vitest core/webview/__tests__/webviewMessageHandler.openFile.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as nodePath from "path" +import * as nodeFs from "fs" +import type { PathLike, Stats } from "fs" +import * as vscode from "vscode" +import { openFile } from "../../../integrations/misc/open-file" +import { webviewMessageHandler } from "../webviewMessageHandler" +import { decodeUntrustedPathToStable, isRealPathOutsideWorkspace } from "../../../utils/pathUtils" +import type { ClineProvider } from "../ClineProvider" +import type { Task } from "../../task/Task" + +vi.mock("../../../api/providers/fetchers/modelCache") + +// Platform-native mock roots: the production isPathOutsideWorkspace resolves +// with node's path module, so POSIX-style roots would not match on Windows. +const IS_WIN = process.platform === "win32" +const WORKSPACE_ROOT = IS_WIN ? "C:\\mock\\workspace" : "/mock/workspace" +const OTHER_ROOT = IS_WIN ? "C:\\mock\\workspace2" : "/mock/workspace2" +const OUTSIDE_ROOT = IS_WIN ? "C:\\outside" : "/outside" +const MOCK_CWD = nodePath.join(WORKSPACE_ROOT, "project") +const OUTSIDE_ABS = nodePath.join(OUTSIDE_ROOT, "passwd") + +// Mirrors the i18n mock's echo format so assertions are exact on every platform. +const cannotAccessPathError = (pathValue: string) => + `common:errors.cannot_access_path:${JSON.stringify({ path: pathValue, error: "common:errors.path_outside_workspace" })}` + +// Mutable holder for the vscode mock (vi.mock factories are hoisted above the +// constants above): tests reassign the workspace folders per case, and the +// production isPathOutsideWorkspace reads them through this getter. +const vscodeState = vi.hoisted(() => ({ + workspaceFolders: [] as { uri: { fsPath: string } }[], +})) + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn(), + }, + workspace: { + get workspaceFolders() { + return vscodeState.workspaceFolders + }, + openTextDocument: vi.fn().mockResolvedValue({}), + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +vi.mock("../../../i18n", () => ({ + // Echo the key with params serialized so tests can assert the full + // message arguments without loading a real i18n catalogue. + t: vi.fn((key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key)), +})) + +vi.mock("../../../utils/fs") +vi.mock("../../../utils/path") +vi.mock("../../../utils/globalContext") + +// The real utils/pathUtils runs unmocked on purpose: the handler's +// containment must be exercised through the production predicate (lexical +// and realpath-based), not a hand-rolled copy. + +vi.mock("../../mentions/resolveImageMentions", () => ({ + resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({ + text, + images: [...(images ?? [])], + })), +})) + +// Mock the openFile module so the test observes the handler's resolved path and +// proves markdown-sourced requests never reach out-of-workspace targets. +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn().mockResolvedValue(undefined), +})) + +// Deterministic real-filesystem model for the realpath-based containment: +// explicit symlink targets plus a set of "existing" paths; everything else is +// ENOENT, so the real-path helper walks up to the deepest existing ancestor. +// Keeps the tests portable without touching the real filesystem. +const realWorld = vi.hoisted(() => ({ + existing: new Set(), + symlinks: new Map(), + // Existing entries whose symlink target is missing: realpath fails with + // ENOENT, lstat still reports them as symlinks. + dangling: new Set(), + // Paths where lstat must throw a specific error code (fail-closed test). + lstatErrors: new Map(), +})) + +const mockProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + customModesManager: { + getCustomModes: vi.fn(), + deleteCustomMode: vi.fn(), + }, + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + contextProxy: { + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + setValue: vi.fn(), + getValue: vi.fn(), + }, + log: vi.fn(), + postStateToWebview: vi.fn(), + getCurrentTask: vi.fn().mockReturnValue({ cwd: MOCK_CWD }), + getTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + cwd: MOCK_CWD, +} as unknown as ClineProvider + +describe("webviewMessageHandler - openFile markdown workspace containment", () => { + beforeEach(() => { + vi.clearAllMocks() + vscodeState.workspaceFolders = [{ uri: { fsPath: WORKSPACE_ROOT } }] + realWorld.existing.clear() + realWorld.symlinks.clear() + realWorld.dangling.clear() + realWorld.lstatErrors.clear() + realWorld.existing.add(WORKSPACE_ROOT) + realWorld.existing.add(MOCK_CWD) + vi.spyOn(nodeFs.promises, "realpath").mockImplementation(async (p: PathLike) => { + const key = String(p) + // A dangling symlink's target is missing, so realpath fails with + // ENOENT exactly like a nonexistent path; lstat distinguishes it. + if (realWorld.dangling.has(key)) { + const err = new Error(`ENOENT: no such file or directory, realpath '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + } + const symlink = realWorld.symlinks.get(key) + if (symlink) { + return symlink + } + if (realWorld.existing.has(key)) { + return key + } + const err = new Error(`ENOENT: no such file or directory, realpath '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + }) + // lstat does not follow the final entry: a dangling symlink is still a + // directory entry and reports as a symlink, while a genuinely absent + // entry is ENOENT. + vi.spyOn(nodeFs.promises, "lstat").mockImplementation(async (p: PathLike) => { + const key = String(p) + const code = realWorld.lstatErrors.get(key) + if (code) { + const err = new Error(`${code}: error, lstat '${key}'`) + ;(err as NodeJS.ErrnoException).code = code + throw err + } + if (realWorld.dangling.has(key) || realWorld.symlinks.has(key) || realWorld.existing.has(key)) { + // Minimal test double; the containment code only reads isSymbolicLink(). + return { isSymbolicLink: () => realWorld.dangling.has(key) || realWorld.symlinks.has(key) } as Stats + } + const err = new Error(`ENOENT: no such file or directory, lstat '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + }) + // The containment logic only reads `cwd`; a full Task would be noise. The single + // assertion is safe because Task is structurally compatible with the stub. + vi.mocked(mockProvider.getCurrentTask).mockReturnValue({ cwd: MOCK_CWD } as Task) + ;(mockProvider as { cwd?: string }).cwd = MOCK_CWD + }) + + // MarkdownBlock tags its openFile posts with fromMarkdown, flagging the + // request as sourced from untrusted task markdown. + it("opens a markdown file within the workspace using a relative path", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { line: 3, fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.resolve(MOCK_CWD, "src/index.ts"), { + line: 3, + fromMarkdown: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("rejects a markdown relative path that traverses outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "../../.env", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("../../.env")) + }) + + it("rejects a markdown absolute path outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: OUTSIDE_ABS, + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError(OUTSIDE_ABS)) + }) + + it("opens a markdown file using an absolute path within the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: nodePath.join(MOCK_CWD, "src/index.ts"), + values: { fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src/index.ts"), { fromMarkdown: true }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + // openFile decodes the path AFTER the containment checks, so percent-encoded + // traversal must be decoded at the containment boundary instead. + it("rejects a percent-encoded traversal (%2e%2e) posted by the webview", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./%2e%2e/%2e%2e/.env", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("./%2e%2e/%2e%2e/.env")) + }) + + it("rejects a double-encoded traversal (%252e%252e) that only escapes after two decodes", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./%252e%252e/%252e%252e/.env", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + cannotAccessPathError("./%252e%252e/%252e%252e/.env"), + ) + }) + + it("opens a legitimately percent-encoded filename within the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/report%202024.txt", + values: { fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src/report 2024.txt"), { fromMarkdown: true }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + // Lexical containment cannot see symlinks: a link inside the workspace may + // resolve to a target outside it. + it("rejects a symlink inside the workspace that resolves outside of it", async () => { + realWorld.symlinks.set(nodePath.join(MOCK_CWD, "link.txt"), nodePath.join(OUTSIDE_ROOT, "secret.txt")) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "link.txt", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("link.txt")) + }) + + // A dangling symlink inside the workspace — an existing entry whose target + // is missing — must be rejected fail-closed: a creation flow (mkdir -p) + // would follow it and could escape the workspace. + it("rejects a tagged path that is a dangling symlink inside the workspace", async () => { + realWorld.dangling.add(nodePath.join(MOCK_CWD, "link-out")) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "link-out", + values: { create: true, fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("link-out")) + }) + + // A workspace root that is itself a symlink must be realized before the + // containment test: a target whose deepest existing ancestor resolves + // inside the real root is inside the workspace. + it("opens a markdown file when the workspace root is a symlink to the real root", async () => { + const realRoot = IS_WIN ? "C:\\mock\\workspace-real" : "/mock/workspace-real" + realWorld.existing.clear() + realWorld.existing.add(realRoot) + realWorld.symlinks.set(WORKSPACE_ROOT, realRoot) + realWorld.symlinks.set(MOCK_CWD, nodePath.join(realRoot, "project")) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { line: 3, fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src/index.ts"), { line: 3, fromMarkdown: true }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + // An unexpected lstat error on the containment walk fails closed. + it("fails closed when lstat errors during the realpath containment walk", async () => { + realWorld.lstatErrors.set(nodePath.join(MOCK_CWD, "note.txt"), "EACCES") + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "note.txt", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("note.txt")) + }) + + it("allows creating a new file under a new directory inside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "newdir/note.txt", + values: { create: true, fromMarkdown: true }, + }) + + // The target does not exist yet, so containment is verified against its + // deepest existing ancestor (MOCK_CWD), which is inside the workspace. + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "newdir/note.txt"), { + create: true, + fromMarkdown: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("accepts a tagged path inside a second workspace folder (multi-root)", async () => { + vscodeState.workspaceFolders = [{ uri: { fsPath: WORKSPACE_ROOT } }, { uri: { fsPath: OTHER_ROOT } }] + realWorld.existing.add(OTHER_ROOT) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: nodePath.join(OTHER_ROOT, "x.ts"), + values: { fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(OTHER_ROOT, "x.ts"), { fromMarkdown: true }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("rejects a tagged request when no workspace folders are configured", async () => { + vscodeState.workspaceFolders = [] + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("src/index.ts")) + }) + + // First-party callers (slash-command settings, modes, MCP) are not flagged + // and keep the previous behavior, including global config files that live + // outside the workspace. + it("keeps legacy behavior for untagged callers opening paths outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "/global/roo/commands/my-command.md", + }) + + expect(openFile).toHaveBeenCalledWith("/global/roo/commands/my-command.md", undefined) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("does nothing when no path is provided", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("shows an error when no workspace cwd is available", async () => { + vi.mocked(mockProvider.getCurrentTask).mockReturnValue(undefined) + ;(mockProvider as { cwd?: string }).cwd = undefined + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'common:errors.could_not_open_file:{"errorMessage":"common:errors.no_workspace"}', + ) + }) + + // The boundary decodes tagged requests only: untagged callers keep the + // legacy path exactly, so openFile's later decode applies exactly once. + it("keeps untagged percent-encoded paths undecoded at the boundary", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/a%20b.txt", + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src/a%20b.txt"), undefined) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + // A hostile encoding that never stabilizes inside the fixed-point bound is + // rejected at the boundary (pinned here and by the direct helper test). + it("rejects a tagged encoding that does not stabilize within the bound", async () => { + const spy = vi.spyOn(globalThis, "decodeURIComponent").mockImplementation((s: string) => `${s}x`) + try { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "a", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("a")) + } finally { + spy.mockRestore() + } + }) + + // Defense in depth: a lexically outside path whose symlinked ancestor + // resolves inside the workspace is still rejected by the lexical check. + it("rejects a lexically outside path even when its symlinked ancestor resolves inside the workspace", async () => { + realWorld.symlinks.set(nodePath.join(OUTSIDE_ROOT, "link-in"), MOCK_CWD) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: nodePath.join(OUTSIDE_ROOT, "link-in", "f.txt"), + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + cannotAccessPathError(nodePath.join(OUTSIDE_ROOT, "link-in", "f.txt")), + ) + }) +}) + +describe("utils/pathUtils containment helpers", () => { + beforeEach(() => { + vi.clearAllMocks() + vscodeState.workspaceFolders = [{ uri: { fsPath: WORKSPACE_ROOT } }] + realWorld.existing.clear() + realWorld.symlinks.clear() + realWorld.dangling.clear() + realWorld.lstatErrors.clear() + realWorld.existing.add(WORKSPACE_ROOT) + realWorld.existing.add(MOCK_CWD) + vi.spyOn(nodeFs.promises, "realpath").mockImplementation(async (p: PathLike) => { + const key = String(p) + // A dangling symlink's target is missing, so realpath fails with + // ENOENT exactly like a nonexistent path; lstat distinguishes it. + if (realWorld.dangling.has(key)) { + const err = new Error(`ENOENT: no such file or directory, realpath '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + } + const symlink = realWorld.symlinks.get(key) + if (symlink) { + return symlink + } + if (realWorld.existing.has(key)) { + return key + } + const err = new Error(`ENOENT: no such file or directory, realpath '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + }) + // lstat does not follow the final entry: a dangling symlink is still a + // directory entry and reports as a symlink, while a genuinely absent + // entry is ENOENT. + vi.spyOn(nodeFs.promises, "lstat").mockImplementation(async (p: PathLike) => { + const key = String(p) + const code = realWorld.lstatErrors.get(key) + if (code) { + const err = new Error(`${code}: error, lstat '${key}'`) + ;(err as NodeJS.ErrnoException).code = code + throw err + } + if (realWorld.dangling.has(key) || realWorld.symlinks.has(key) || realWorld.existing.has(key)) { + // Minimal test double; the containment code only reads isSymbolicLink(). + return { isSymbolicLink: () => realWorld.dangling.has(key) || realWorld.symlinks.has(key) } as Stats + } + const err = new Error(`ENOENT: no such file or directory, lstat '${key}'`) + ;(err as NodeJS.ErrnoException).code = "ENOENT" + throw err + }) + }) + + describe("decodeUntrustedPathToStable", () => { + it("returns a stable path unchanged", () => { + expect(decodeUntrustedPathToStable("src/index.ts")).toBe("src/index.ts") + }) + + it("decodes to a fixed point", () => { + expect(decodeUntrustedPathToStable("./%2e%2e/%2e%2e/.env")).toBe("./../../.env") + expect(decodeUntrustedPathToStable("%252e%252e")).toBe("..") + }) + + it("leaves a bare percent that is not a valid escape unchanged", () => { + expect(decodeUntrustedPathToStable("report 50%.md")).toBe("report 50%.md") + }) + + it("returns null when the value does not stabilize within the bound", () => { + const spy = vi.spyOn(globalThis, "decodeURIComponent").mockImplementation((s: string) => `${s}x`) + try { + expect(decodeUntrustedPathToStable("a")).toBeNull() + } finally { + spy.mockRestore() + } + }) + }) + + describe("isRealPathOutsideWorkspace", () => { + it("fails closed when no workspace folders are configured", async () => { + vscodeState.workspaceFolders = [] + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "x.ts"))).resolves.toBe(true) + }) + + it("accepts an existing path inside the workspace", async () => { + realWorld.existing.add(nodePath.join(MOCK_CWD, "x.ts")) + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "x.ts"))).resolves.toBe(false) + }) + + it("accepts a missing file via its deepest existing in-workspace ancestor", async () => { + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "newdir", "x.ts"))).resolves.toBe(false) + }) + + it("rejects a symlink whose target is outside the workspace", async () => { + realWorld.symlinks.set(nodePath.join(MOCK_CWD, "link.txt"), nodePath.join(OUTSIDE_ROOT, "secret.txt")) + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "link.txt"))).resolves.toBe(true) + }) + + it("counts a dangling symlink as outside the workspace (fail-closed)", async () => { + realWorld.dangling.add(nodePath.join(MOCK_CWD, "gone")) + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "gone"))).resolves.toBe(true) + }) + + it("fails closed when lstat returns an unexpected error", async () => { + realWorld.lstatErrors.set(nodePath.join(MOCK_CWD, "blocked"), "EACCES") + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "blocked"))).resolves.toBe(true) + }) + + it("fails closed on an unexpected filesystem error", async () => { + const err = new Error("EACCES: permission denied") + ;(err as NodeJS.ErrnoException).code = "EACCES" + const spy = vi.spyOn(nodeFs.promises, "realpath").mockRejectedValue(err) + try { + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "x.ts"))).resolves.toBe(true) + } finally { + spy.mockRestore() + } + }) + + it("fails closed when no ancestor exists down to the filesystem root", async () => { + // Nothing exists in the model: the ancestor walk reaches the root + // guard instead of an existing ancestor, and containment fails closed. + realWorld.existing.clear() + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "x.ts"))).resolves.toBe(true) + }) + + it("fails closed when a workspace folder cannot be realized", async () => { + realWorld.existing.add(nodePath.join(MOCK_CWD, "x.ts")) + const err = new Error("EACCES: permission denied") + ;(err as NodeJS.ErrnoException).code = "EACCES" + vi.spyOn(nodeFs.promises, "realpath").mockImplementation(async (p: PathLike) => { + const key = String(p) + if (key === WORKSPACE_ROOT) { + throw err + } + const symlink = realWorld.symlinks.get(key) + if (symlink) { + return symlink + } + if (realWorld.existing.has(key)) { + return key + } + const enoent = new Error(`ENOENT: no such file or directory, realpath '${key}'`) + ;(enoent as NodeJS.ErrnoException).code = "ENOENT" + throw enoent + }) + + await expect(isRealPathOutsideWorkspace(nodePath.join(MOCK_CWD, "x.ts"))).resolves.toBe(true) + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..a41fda1ce1 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -83,7 +83,7 @@ import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { isPathOutsideWorkspace, decodeUntrustedPathToStable, isRealPathOutsideWorkspace } from "../../utils/pathUtils" import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" @@ -1515,13 +1515,70 @@ export const webviewMessageHandler = async ( } } break - case "openFile": - let filePath: string = message.text! + case "openFile": { + const rawPath = message.text || "" + if (!rawPath) { + break + } + // Task markdown links are untrusted, so markdown-sourced openFile + // requests (flagged by the webview with fromMarkdown) must resolve + // inside the current workspace. First-party callers (modes, MCP, + // slash-command settings) may legitimately open global config files + // outside the workspace, so they keep the previous behavior. + const fromMarkdown = message.values?.fromMarkdown === true + const rejectOutsideWorkspace = () => { + void vscode.window.showErrorMessage( + t("common:errors.cannot_access_path", { + path: rawPath, + error: t("common:errors.path_outside_workspace"), + }), + ) + } + let filePath = rawPath + // Markdown link targets are URL syntax: percent-decode to a fixed point + // here, at the containment boundary. openFile decodes AFTER this check, + // so a request like `%2e%2e/%2e%2e/.env` would otherwise pass + // containment as a literal and escape only after that later decode. + if (fromMarkdown) { + const decoded = decodeUntrustedPathToStable(rawPath) + // Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive + if (decoded === null) { + // Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values + rejectOutsideWorkspace() + break + } + filePath = decoded + } if (!path.isAbsolute(filePath)) { - filePath = path.join(getCurrentCwd(), filePath) + const cwd = getCurrentCwd() + if (!cwd) { + void vscode.window.showErrorMessage( + t("common:errors.could_not_open_file", { errorMessage: t("common:errors.no_workspace") }), + ) + break + } + filePath = path.resolve(cwd, filePath) + } + // Workspace-boundary validation (defense in depth): the webview already + // rejects traversal in markdown anchors, but refuse any markdown path + // that still resolves outside the workspace. + if (fromMarkdown && isPathOutsideWorkspace(filePath)) { + rejectOutsideWorkspace() + break } - await openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) + // Lexical containment cannot see symlinks: a link inside a workspace + // folder may resolve to a target outside the workspace. Re-check the + // real filesystem path (failing closed) before opening. + if (fromMarkdown && (await isRealPathOutsideWorkspace(filePath))) { + rejectOutsideWorkspace() + break + } + await openFile( + filePath, + message.values as { create?: boolean; content?: string; line?: number; fromMarkdown?: boolean }, + ) break + } case "readFileContent": { const relPath = message.text || "" if (!relPath) { diff --git a/src/i18n/__tests__/locale-bundles.spec.ts b/src/i18n/__tests__/locale-bundles.spec.ts new file mode 100644 index 0000000000..cb455e2d65 --- /dev/null +++ b/src/i18n/__tests__/locale-bundles.spec.ts @@ -0,0 +1,60 @@ +// npx vitest i18n/__tests__/locale-bundles.spec.ts +// +// The i18n setup loads locale bundles from disk outside the test environment, +// so no other suite imports the bundle files. This spec imports every bundle +// directly: it pins the completeness of the keys this unit adds, and it puts +// the bundle files into the coverage report so changed-line coverage can +// measure them. + +import { describe, it, expect } from "vitest" +import ca from "../locales/ca/common.json" +import de from "../locales/de/common.json" +import en from "../locales/en/common.json" +import es from "../locales/es/common.json" +import fr from "../locales/fr/common.json" +import hi from "../locales/hi/common.json" +import id from "../locales/id/common.json" +import itBundle from "../locales/it/common.json" +import ja from "../locales/ja/common.json" +import ko from "../locales/ko/common.json" +import nl from "../locales/nl/common.json" +import pl from "../locales/pl/common.json" +import ptBr from "../locales/pt-BR/common.json" +import ru from "../locales/ru/common.json" +import tr from "../locales/tr/common.json" +import vi from "../locales/vi/common.json" +import zhCn from "../locales/zh-CN/common.json" +import zhTw from "../locales/zh-TW/common.json" + +const bundles: Record }> = { + ca, + de, + en, + es, + fr, + hi, + id, + it: itBundle, + ja, + ko, + nl, + pl, + "pt-BR": ptBr, + ru, + tr, + vi, + "zh-CN": zhCn, + "zh-TW": zhTw, +} + +// Keys this unit adds to the common namespace (the openFile workspace +// containment error posted by webviewMessageHandler). +const addedKeys = ["path_outside_workspace"] + +describe("common locale bundles", () => { + it.each(Object.keys(bundles))("%s defines every key this unit adds", (locale) => { + for (const key of addedKeys) { + expect(bundles[locale].errors[key]).toEqual(expect.any(String)) + } + }) +}) diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 24ae3f310c..a85c19cec4 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -59,6 +59,7 @@ "failed_remove_directory": "Ha fallat l'eliminació del directori de tasques: {{error}}", "custom_storage_path_unusable": "La ruta d'emmagatzematge personalitzada \"{{path}}\" no és utilitzable, s'utilitzarà la ruta predeterminada", "cannot_access_path": "No es pot accedir a la ruta {{path}}: {{error}}", + "path_outside_workspace": "La ruta és fora de l'espai de treball", "settings_import_failed": "Ha fallat la importació de la configuració: {{error}}.", "mistake_limit_guidance": "Això pot indicar un error en el procés de pensament del model o la incapacitat d'utilitzar una eina correctament, que es pot mitigar amb orientació de l'usuari (p. ex. \"Prova de dividir la tasca en passos més petits\").", "violated_organization_allowlist": "Ha fallat l'execució de la tasca: el perfil actual no és compatible amb la configuració de la teva organització", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 54fa0b3c22..9f2b6d2056 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Fehler beim Entfernen des Aufgabenverzeichnisses: {{error}}", "custom_storage_path_unusable": "Benutzerdefinierter Speicherpfad \"{{path}}\" ist nicht verwendbar, Standardpfad wird verwendet", "cannot_access_path": "Zugriff auf Pfad {{path}} nicht möglich: {{error}}", + "path_outside_workspace": "Pfad liegt außerhalb des Arbeitsbereichs", "settings_import_failed": "Fehler beim Importieren der Einstellungen: {{error}}.", "mistake_limit_guidance": "Dies kann auf einen Fehler im Denkprozess des Modells oder die Unfähigkeit hinweisen, ein Tool richtig zu verwenden, was durch Benutzerführung behoben werden kann (z.B. \"Versuche, die Aufgabe in kleinere Schritte zu unterteilen\").", "violated_organization_allowlist": "Aufgabe konnte nicht ausgeführt werden: Das aktuelle Profil ist nicht kompatibel mit den Einstellungen deiner Organisation", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 516a3d4f88..507780366a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Failed to remove task directory: {{error}}", "custom_storage_path_unusable": "Custom storage path \"{{path}}\" is unusable, will use default path", "cannot_access_path": "Cannot access path {{path}}: {{error}}", + "path_outside_workspace": "Path is outside the workspace", "settings_import_failed": "Settings import failed: {{error}}.", "mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").", "violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 71dc994516..f646f5c4f3 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Error al eliminar el directorio de tareas: {{error}}", "custom_storage_path_unusable": "La ruta de almacenamiento personalizada \"{{path}}\" no es utilizable, se usará la ruta predeterminada", "cannot_access_path": "No se puede acceder a la ruta {{path}}: {{error}}", + "path_outside_workspace": "La ruta está fuera del espacio de trabajo", "settings_import_failed": "Error al importar la configuración: {{error}}.", "mistake_limit_guidance": "Esto puede indicar un fallo en el proceso de pensamiento del modelo o la incapacidad de usar una herramienta correctamente, lo cual puede mitigarse con orientación del usuario (ej. \"Intenta dividir la tarea en pasos más pequeños\").", "violated_organization_allowlist": "Error al ejecutar la tarea: el perfil actual no es compatible con la configuración de tu organización", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 87009ee988..0e5f59ca1e 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Échec de la suppression du répertoire de tâches : {{error}}", "custom_storage_path_unusable": "Le chemin de stockage personnalisé \"{{path}}\" est inutilisable, le chemin par défaut sera utilisé", "cannot_access_path": "Impossible d'accéder au chemin {{path}} : {{error}}", + "path_outside_workspace": "Le chemin est à l'extérieur de l'espace de travail", "settings_import_failed": "Échec de l'importation des paramètres : {{error}}", "mistake_limit_guidance": "Cela peut indiquer un échec dans le processus de réflexion du modèle ou une incapacité à utiliser un outil correctement, ce qui peut être atténué avec des conseils de l'utilisateur (par ex. \"Essaie de diviser la tâche en étapes plus petites\").", "violated_organization_allowlist": "Échec de l'exécution de la tâche : le profil actuel n'est pas compatible avec les paramètres de votre organisation", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f4bd1c3055..59cf0f0657 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "टास्क डायरेक्टरी हटाने में विफल: {{error}}", "custom_storage_path_unusable": "कस्टम स्टोरेज पाथ \"{{path}}\" उपयोग योग्य नहीं है, डिफ़ॉल्ट पाथ का उपयोग किया जाएगा", "cannot_access_path": "पाथ {{path}} तक पहुंच नहीं पा रहे हैं: {{error}}", + "path_outside_workspace": "पथ वर्कस्पेस से बाहर है", "settings_import_failed": "सेटिंग्स इम्पोर्ट करने में विफल: {{error}}।", "mistake_limit_guidance": "यह मॉडल की सोच प्रक्रिया में विफलता या किसी टूल का सही उपयोग न कर पाने का संकेत हो सकता है, जिसे उपयोगकर्ता के मार्गदर्शन से ठीक किया जा सकता है (जैसे \"कार्य को छोटे चरणों में बांटने की कोशिश करें\")।", "violated_organization_allowlist": "कार्य चलाने में विफल: वर्तमान प्रोफ़ाइल आपके संगठन की सेटिंग्स के साथ संगत नहीं है", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index bcee321af5..87ce78d209 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Gagal menghapus direktori tugas: {{error}}", "custom_storage_path_unusable": "Path penyimpanan kustom \"{{path}}\" tidak dapat digunakan, akan menggunakan path default", "cannot_access_path": "Tidak dapat mengakses path {{path}}: {{error}}", + "path_outside_workspace": "Path berada di luar workspace", "settings_import_failed": "Impor pengaturan gagal: {{error}}.", "mistake_limit_guidance": "Ini mungkin menunjukkan kegagalan dalam proses pemikiran model atau ketidakmampuan untuk menggunakan tool dengan benar, yang dapat diatasi dengan beberapa panduan pengguna (misalnya \"Coba bagi tugas menjadi langkah-langkah yang lebih kecil\").", "violated_organization_allowlist": "Gagal menjalankan tugas: profil saat ini tidak kompatibel dengan pengaturan organisasi kamu", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 395be16b84..ce7ffce090 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Impossibile rimuovere la directory delle attività: {{error}}", "custom_storage_path_unusable": "Il percorso di archiviazione personalizzato \"{{path}}\" non è utilizzabile, verrà utilizzato il percorso predefinito", "cannot_access_path": "Impossibile accedere al percorso {{path}}: {{error}}", + "path_outside_workspace": "Il percorso è fuori dall'area di lavoro", "settings_import_failed": "Importazione delle impostazioni fallita: {{error}}.", "mistake_limit_guidance": "Questo può indicare un fallimento nel processo di pensiero del modello o l'incapacità di utilizzare correttamente uno strumento, che può essere mitigato con la guida dell'utente (ad es. \"Prova a suddividere l'attività in passaggi più piccoli\").", "violated_organization_allowlist": "Impossibile eseguire l'attività: il profilo corrente non è compatibile con le impostazioni della tua organizzazione", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7dccfcd837..d412e8f8e0 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "タスクディレクトリの削除に失敗しました:{{error}}", "custom_storage_path_unusable": "カスタムストレージパス \"{{path}}\" が使用できないため、デフォルトパスを使用します", "cannot_access_path": "パス {{path}} にアクセスできません:{{error}}", + "path_outside_workspace": "パスはワークスペースの外にあります", "settings_import_failed": "設定のインポートに失敗しました:{{error}}", "mistake_limit_guidance": "これは、モデルの思考プロセスの失敗やツールを適切に使用できないことを示している可能性があり、ユーザーのガイダンスによって軽減できます(例:「タスクをより小さなステップに分割してみてください」)。", "violated_organization_allowlist": "タスクの実行に失敗しました: 現在のプロファイルは組織の設定と互換性がありません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0ca65be687..ff2fcb10d9 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "작업 디렉토리 제거 실패: {{error}}", "custom_storage_path_unusable": "사용자 지정 저장 경로 \"{{path}}\"를 사용할 수 없어 기본 경로를 사용합니다", "cannot_access_path": "경로 {{path}}에 접근할 수 없습니다: {{error}}", + "path_outside_workspace": "경로가 작업 영역 밖에 있습니다", "settings_import_failed": "설정 가져오기 실패: {{error}}.", "mistake_limit_guidance": "이는 모델의 사고 과정 실패나 도구를 제대로 사용하지 못하는 것을 나타낼 수 있으며, 사용자 가이드를 통해 완화할 수 있습니다 (예: \"작업을 더 작은 단계로 나누어 시도해보세요\").", "violated_organization_allowlist": "작업 실행 실패: 현재 프로필이 조직 설정과 호환되지 않습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index a38415edfd..e8b639af5d 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Verwijderen van taakmap mislukt: {{error}}", "custom_storage_path_unusable": "Aangepast opslagpad \"{{path}}\" is onbruikbaar, standaardpad wordt gebruikt", "cannot_access_path": "Kan pad {{path}} niet openen: {{error}}", + "path_outside_workspace": "Pad is buiten de werkomgeving", "settings_import_failed": "Importeren van instellingen mislukt: {{error}}.", "mistake_limit_guidance": "Dit kan duiden op een fout in het denkproces van het model of het onvermogen om een tool correct te gebruiken, wat kan worden verminderd met gebruikersbegeleiding (bijv. \"Probeer de taak op te delen in kleinere stappen\").", "violated_organization_allowlist": "Taak uitvoeren mislukt: het huidige profiel is niet compatibel met de instellingen van uw organisatie", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index ff898e8987..73e2293a10 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Nie udało się usunąć katalogu zadania: {{error}}", "custom_storage_path_unusable": "Niestandardowa ścieżka przechowywania \"{{path}}\" nie jest użyteczna, zostanie użyta domyślna ścieżka", "cannot_access_path": "Nie można uzyskać dostępu do ścieżki {{path}}: {{error}}", + "path_outside_workspace": "Ścieżka znajduje się poza obszarem roboczym", "settings_import_failed": "Nie udało się zaimportować ustawień: {{error}}.", "mistake_limit_guidance": "To może wskazywać na błąd w procesie myślowym modelu lub niezdolność do prawidłowego użycia narzędzia, co można złagodzić poprzez wskazówki użytkownika (np. \"Spróbuj podzielić zadanie na mniejsze kroki\").", "violated_organization_allowlist": "Nie udało się uruchomić zadania: bieżący profil nie jest kompatybilny z ustawieniami Twojej organizacji", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index d3c31ed2dd..82654929b4 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -59,6 +59,7 @@ "failed_remove_directory": "Falha ao remover o diretório de tarefas: {{error}}", "custom_storage_path_unusable": "O caminho de armazenamento personalizado \"{{path}}\" não pode ser usado, será usado o caminho padrão", "cannot_access_path": "Não é possível acessar o caminho {{path}}: {{error}}", + "path_outside_workspace": "O caminho está fora do espaço de trabalho", "settings_import_failed": "Falha ao importar configurações: {{error}}", "mistake_limit_guidance": "Isso pode indicar uma falha no processo de pensamento do modelo ou incapacidade de usar uma ferramenta adequadamente, o que pode ser mitigado com orientação do usuário (ex. \"Tente dividir a tarefa em etapas menores\").", "violated_organization_allowlist": "Falha ao executar a tarefa: o perfil atual não é compatível com as configurações da sua organização", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 08d2e2aa2c..5857b1290f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Не удалось удалить директорию задачи: {{error}}", "custom_storage_path_unusable": "Пользовательский путь хранения \"{{path}}\" непригоден, будет использован путь по умолчанию", "cannot_access_path": "Невозможно получить доступ к пути {{path}}: {{error}}", + "path_outside_workspace": "Путь находится вне рабочего пространства", "settings_import_failed": "Не удалось импортировать настройки: {{error}}.", "mistake_limit_guidance": "Это может указывать на сбой в процессе мышления модели или неспособность правильно использовать инструмент, что можно смягчить с помощью руководства пользователя (например, \"Попробуйте разбить задачу на более мелкие шаги\").", "violated_organization_allowlist": "Не удалось выполнить задачу: текущий профиль несовместим с настройками вашей организации", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 716ccbc6de..48faf7d03c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Görev dizini kaldırılamadı: {{error}}", "custom_storage_path_unusable": "Özel depolama yolu \"{{path}}\" kullanılamıyor, varsayılan yol kullanılacak", "cannot_access_path": "{{path}} yoluna erişilemiyor: {{error}}", + "path_outside_workspace": "Yol çalışma alanının dışında", "settings_import_failed": "Ayarlar içe aktarılamadı: {{error}}.", "mistake_limit_guidance": "Bu, modelin düşünce sürecindeki bir başarısızlığı veya bir aracı düzgün kullanamama durumunu gösterebilir, bu da kullanıcı rehberliği ile hafifletilebilir (örn. \"Görevi daha küçük adımlara bölmeyi deneyin\").", "violated_organization_allowlist": "Görev yürütülemedi: Geçerli profil kuruluşunuzun ayarlarıyla uyumlu değil", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 69c6343c31..5651e0eb3b 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Không thể xóa thư mục nhiệm vụ: {{error}}", "custom_storage_path_unusable": "Đường dẫn lưu trữ tùy chỉnh \"{{path}}\" không thể sử dụng được, sẽ sử dụng đường dẫn mặc định", "cannot_access_path": "Không thể truy cập đường dẫn {{path}}: {{error}}", + "path_outside_workspace": "Đường dẫn nằm ngoài không gian làm việc", "settings_import_failed": "Nhập cài đặt thất bại: {{error}}.", "mistake_limit_guidance": "Điều này có thể cho thấy sự thất bại trong quá trình suy nghĩ của mô hình hoặc không thể sử dụng công cụ đúng cách, có thể được giảm thiểu bằng hướng dẫn của người dùng (ví dụ: \"Hãy thử chia nhỏ nhiệm vụ thành các bước nhỏ hơn\").", "violated_organization_allowlist": "Không thể chạy tác vụ: hồ sơ hiện tại không tương thích với cài đặt của tổ chức của bạn", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 3600f0aa7c..e5ecf86da2 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -60,6 +60,7 @@ "failed_remove_directory": "删除任务目录失败:{{error}}", "custom_storage_path_unusable": "自定义存储路径 \"{{path}}\" 不可用,将使用默认路径", "cannot_access_path": "无法访问路径 {{path}}:{{error}}", + "path_outside_workspace": "路径位于工作区之外", "settings_import_failed": "设置导入失败:{{error}}。", "mistake_limit_guidance": "这可能表明模型思维过程失败或无法正确使用工具,可通过用户指导来缓解(例如\"尝试将任务分解为更小的步骤\")。", "violated_organization_allowlist": "执行任务失败:当前配置文件与您的组织设置不兼容", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c635769891..e70c464da0 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "刪除工作目錄失敗:{{error}}", "custom_storage_path_unusable": "自訂儲存路徑 \"{{path}}\" 無法使用,將使用預設路徑", "cannot_access_path": "無法存取路徑 {{path}}:{{error}}", + "path_outside_workspace": "路徑位於工作區之外", "settings_import_failed": "設定匯入失敗:{{error}}。", "mistake_limit_guidance": "這可能表明模型思維過程失敗或無法正確使用工具,可透過使用者指導來緩解(例如「嘗試將工作分解為更小的步驟」)。", "violated_organization_allowlist": "執行工作失敗:目前設定檔與您的組織設定不相容", diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index dae300f8f3..c433d4737d 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import * as path from "path" +import * as fs from "fs" /** * Checks if a file path is outside all workspace folders @@ -22,3 +23,122 @@ export function isPathOutsideWorkspace(filePath: string): boolean { return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep) }) } + +/** + * Percent-decode an untrusted path to a fixed point. + * + * Markdown link targets are URL syntax and may be percent-encoded, while the + * downstream openFile helper (src/integrations/misc/open-file.ts) decodes the + * path AFTER workspace validation. Decoding here, at the containment + * boundary, closes the hole where a request like `%2e%2e/%2e%2e/.env` passes + * containment as a literal and escapes only after the later decode. Decoding + * repeats until the value is stable so double-encoded payloads + * (`%252e%252e`) are caught as well. + * + * Values containing a bare `%` that is not a valid escape (e.g. + * `report 50%.md`) are returned unchanged — the same lenient semantics + * openFile already applies. A value that does not stabilize within + * `maxIterations` is a hostile encoding and rejected with `null`. + */ +export function decodeUntrustedPathToStable(filePath: string, maxIterations = 8): string | null { + let current = filePath + // Stryker disable next-line EqualityOperator,UpdateOperator,ConditionalExpression: defensive iteration bound; valid percent-encoding strictly reduces escape depth per decode, so the bound is never hit for production input (pinned by the direct bound test) + for (let i = 0; i < maxIterations; i++) { + let decoded: string + try { + decoded = decodeURIComponent(current) + } catch { + // Not a valid escape sequence: the value is stable as-is. + return current + } + if (decoded === current) { + return current + } + current = decoded + } + // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: defensive bound; valid percent-encoding strictly reduces escape depth per iteration, so non-stabilization is unreachable for real input + return null +} + +// Realpath of the deepest existing ancestor of `filePath` (the path itself +// when it exists). A dangling symlink — an entry that exists but whose target +// cannot be resolved — fails realpath with ENOENT without being a +// nonexistent path: creation flows (mkdir -p) would follow it and could +// escape the workspace, so such entries fail closed. Returns null on +// unexpected errors: containment for untrusted paths fails closed. +async function realPathOfExistingAncestor(filePath: string): Promise { + let current = filePath + for (;;) { + try { + return await fs.promises.realpath(current) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: ENOENT is the only expected code on a walk toward an existing ancestor; anything else (EACCES, EIO, ...) must fail closed + if (code !== "ENOENT") { + return null + } + // ENOENT from realpath is ambiguous: the entry may simply not exist + // yet, or it may be a dangling symlink whose target is missing. + // lstat does not follow the final entry, so it distinguishes the two. + // A dangling symlink must fail closed (a creation flow would follow + // it and could escape the workspace); a genuinely absent entry keeps + // walking to its deepest existing ancestor. + try { + const stat = await fs.promises.lstat(current) + // Stryker disable next-line ConditionalExpression: equivalent - lstat succeeding on an entry whose realpath just failed with ENOENT means the entry is a dangling symlink (a real non-symlink entry would make realpath succeed), so the condition is always true at every reachable point and the true/false replacements are unobservable; the symlink reject is pinned by the dangling-symlink tests + if (stat.isSymbolicLink()) { + return null + } + } catch (lstatError) { + const lstatCode = (lstatError as NodeJS.ErrnoException).code + // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: only ENOENT means the entry is genuinely absent and the walk may continue; any other lstat error (EACCES, EIO, ...) must fail closed + if (lstatCode !== "ENOENT") { + return null + } + } + const parent = path.dirname(current) + // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: root guard against a non-terminating walk (path.dirname stabilizes at the filesystem root) + if (parent === current) { + return null + } + current = parent + } + } +} + +/** + * Realpath-based containment for untrusted paths: resolves the filesystem's + * real targets before checking containment, so a symlink inside a workspace + * folder cannot point at a file outside it. Paths that do not exist yet (the + * openFile creation flow) are checked via their deepest existing ancestor. + * Workspace folders are realized the same way, since a workspace root may + * itself be a symlink. Fails closed: no workspace folders, or an unexpected + * fs error, counts as outside. + */ +export async function isRealPathOutsideWorkspace(filePath: string): Promise { + const folders = vscode.workspace.workspaceFolders + // Stryker disable next-line ConditionalExpression,LogicalOperator: equivalent - with no folders the realFolders set stays empty and the empty-set fail-closed below returns the same result + if (!folders || folders.length === 0) { + // Stryker disable next-line BlockStatement: no workspace means no allowed root; everything is outside (mirrors isPathOutsideWorkspace) + return true + } + const target = await realPathOfExistingAncestor(filePath) + // Stryker disable next-line ConditionalExpression,BlockStatement: unresolvable target (unexpected fs error) fails closed + if (!target) { + return true + } + // Stryker disable next-line ArrayDeclaration: equivalent - the array is always populated (or fails closed at the empty check) before the containment test, so the initial literal is unobservable + const realFolders: string[] = [] + for (const folder of folders) { + const real = await realPathOfExistingAncestor(folder.uri.fsPath) + // Stryker disable next-line ConditionalExpression: equivalent - a null real can never match a resolved target, and the empty-set fail-closed is unaffected by an extra null entry + if (real) { + realFolders.push(real) + } + } + // Stryker disable next-line EqualityOperator,ConditionalExpression,BlockStatement: if no folder could be realized the containment set is empty, so fail closed + if (realFolders.length === 0) { + return true + } + return !realFolders.some((folderPath) => target === folderPath || target.startsWith(folderPath + path.sep)) +} From 673ab1f7a441a94985607531601440843a7d6703 Mon Sep 17 00:00:00 2001 From: seeones Date: Sun, 27 Sep 2026 07:31:57 +0800 Subject: [PATCH 02/35] feat: inline chat model selector (split from chat-input-ux) Extract the chat input model picker (ChatModelSelector + useChatModelSelector) into its own PR so each PR stays under the mutation-diff changed-lines gate. Mounts between ApiConfigSelector and AutoApproveDropdown in the chat composer action bar. Includes the Tab-navigation visual test (loop widened to 50 stops for the extra button) and the composer snapshots rendered with the selector present. --- .../src/components/chat/ChatModelSelector.tsx | 172 ++++++++ .../src/components/chat/ChatTextArea.tsx | 6 + .../chat/__tests__/ChatModelSelector.spec.tsx | 179 +++++++++ .../chat/__tests__/ChatTextArea.visual.tsx | 2 +- .../chat-composer-focus-dark.png | Bin 9166 -> 9949 bytes ...hat-composer-focus-high-contrast-light.png | Bin 9606 -> 10270 bytes .../chat-composer-focus-high-contrast.png | Bin 9624 -> 10477 bytes .../chat-composer-focus-light.png | Bin 9326 -> 9973 bytes .../chat-composer-resting-dark.png | Bin 9162 -> 9945 bytes ...t-composer-resting-high-contrast-light.png | Bin 9606 -> 10270 bytes .../chat-composer-resting-high-contrast.png | Bin 9624 -> 10477 bytes .../chat-composer-resting-light.png | Bin 9326 -> 9973 bytes .../__tests__/useChatModelSelector.spec.tsx | 337 ++++++++++++++++ .../chat/hooks/useChatModelSelector.ts | 367 ++++++++++++++++++ webview-ui/src/i18n/locales/ca/chat.json | 6 +- webview-ui/src/i18n/locales/de/chat.json | 6 +- webview-ui/src/i18n/locales/en/chat.json | 6 +- webview-ui/src/i18n/locales/es/chat.json | 6 +- webview-ui/src/i18n/locales/fr/chat.json | 6 +- webview-ui/src/i18n/locales/hi/chat.json | 6 +- webview-ui/src/i18n/locales/id/chat.json | 6 +- webview-ui/src/i18n/locales/it/chat.json | 6 +- webview-ui/src/i18n/locales/ja/chat.json | 6 +- webview-ui/src/i18n/locales/ko/chat.json | 6 +- webview-ui/src/i18n/locales/nl/chat.json | 6 +- webview-ui/src/i18n/locales/pl/chat.json | 6 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 6 +- webview-ui/src/i18n/locales/ru/chat.json | 6 +- webview-ui/src/i18n/locales/tr/chat.json | 6 +- webview-ui/src/i18n/locales/vi/chat.json | 6 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 6 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 6 +- 32 files changed, 1152 insertions(+), 19 deletions(-) create mode 100644 webview-ui/src/components/chat/ChatModelSelector.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx create mode 100644 webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx create mode 100644 webview-ui/src/components/chat/hooks/useChatModelSelector.ts diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx new file mode 100644 index 0000000000..7cd3450e01 --- /dev/null +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState, useCallback } from "react" + +import { cn } from "@/lib/utils" +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { filterModels } from "@/components/settings/utils/organizationFilters" +import { useChatModelSelector } from "./hooks/useChatModelSelector" + +interface ChatModelSelectorProps { + disabled?: boolean + title: string + triggerClassName?: string +} + +export const ChatModelSelector = ({ disabled = false, title, triggerClassName = "" }: ChatModelSelectorProps) => { + const { t } = useAppTranslation() + const { apiConfiguration, organizationAllowList, currentApiConfigName } = useExtensionState() + const { id: selectedModelId } = useSelectedModel(apiConfiguration) + const { provider, models, modelIdKey, defaultModelId, isLoading, valueTransform, displayTransform } = + useChatModelSelector() + + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const portalContainer = useRooPortal("roo-portal") + + // Filter deprecated models but always keep the currently selected one visible. + const modelIds = useMemo(() => { + const filteredModels = filterModels(models, provider, organizationAllowList) + const available = Object.entries(filteredModels ?? {}) + .filter(([modelId, modelInfo]) => { + if (modelId === selectedModelId) return true + return !modelInfo.deprecated + }) + .map(([modelId]) => modelId) + .sort((a, b) => a.localeCompare(b)) + return available + }, [models, provider, organizationAllowList, selectedModelId]) + + // Resolve the display value (custom transform for compound config values like VSCode LM). + const displayValue = useMemo(() => { + if (displayTransform && modelIdKey) { + const storedValue = apiConfiguration?.[modelIdKey] + return storedValue ? displayTransform(storedValue) : undefined + } + return selectedModelId || (searchValue ? searchValue : undefined) + }, [displayTransform, modelIdKey, apiConfiguration, selectedModelId, searchValue]) + + const filteredModelIds = useMemo(() => { + if (!searchValue) return modelIds + const q = searchValue.toLowerCase() + return modelIds.filter((id) => id.toLowerCase().includes(q)) + }, [modelIds, searchValue]) + + const onSelect = useCallback( + (modelId: string) => { + if (!modelId || !modelIdKey || !apiConfiguration) { + return + } + + setOpen(false) + setSearchValue("") + + // Transform the model id for storage if needed (e.g. VSCode LM selector object). + const valueToStore = valueTransform ? valueTransform(modelId) : modelId + + // Persist the change to the current API configuration profile. The + // backend will save the profile, activate it and broadcast the + // updated apiConfiguration back to the webview. + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + [modelIdKey]: valueToStore, + }, + }) + }, + [modelIdKey, apiConfiguration, valueTransform, currentApiConfigName], + ) + + const onClearSearch = useCallback(() => { + setSearchValue("") + }, []) + + return ( + + + + + {isLoading ? "…" : displayValue || defaultModelId || t("chat:selectModel")} + + + + +
+
+ setSearchValue(e.target.value)} + placeholder={t("chat:searchModel")} + className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" + autoFocus + /> + {searchValue.length > 0 && ( +
+ +
+ )} +
+ + {modelIds.length === 0 && !isLoading && ( +
{t("chat:modelListEmpty")}
+ )} + + {modelIds.length > 0 && ( +
+ {filteredModelIds.map((modelId) => ( +
onSelect(modelId)} + data-testid={`chat-model-option-${modelId}`} + className={cn( + "px-3 py-1.5 text-sm cursor-pointer flex items-center gap-2", + "hover:bg-vscode-list-hoverBackground", + modelId === displayValue && + "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", + )}> + + {modelId} + + {modelId === displayValue && } +
+ ))} +
+ )} + + {searchValue && !modelIds.includes(searchValue) && ( +
onSelect(searchValue)} + className="px-3 py-1.5 text-sm cursor-pointer hover:bg-vscode-list-hoverBackground border-t border-vscode-input-border"> + {t("chat:useCustomModel", { modelId: searchValue })} +
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2c539766fe..27150a9e92 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui" import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" +import { ChatModelSelector } from "./ChatModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -1319,6 +1320,11 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx new file mode 100644 index 0000000000..a819971bcb --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -0,0 +1,179 @@ +import type { ReactNode } from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" +import { providerIdentifiers } from "@roo-code/types" +import { vscode } from "@/utils/vscode" + +import { ChatModelSelector } from "../ChatModelSelector" +import { useChatModelSelector } from "../hooks/useChatModelSelector" + +// Mock the dependencies +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +// Mock the ExtensionStateContext +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }), +})) + +// Mock useSelectedModel +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ id: "claude-opus-4-20250514", info: undefined }), +})) + +// Mock useChatModelSelector hook +vi.mock("../hooks/useChatModelSelector", () => ({ + useChatModelSelector: vi.fn(), +})) + +// Mock Popover components to be testable +vi.mock("@/components/ui", () => { + type PopoverProps = { children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void } + type TriggerProps = { + children: ReactNode + disabled?: boolean + className?: string + onClick?: () => void + } + return { + Popover: ({ children, open }: PopoverProps) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled, className, onClick }: TriggerProps) => ( + + ), + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + StandardTooltip: ({ children }: { children: ReactNode }) => <>{children}, + } +}) + +const mockUseChatModelSelector = useChatModelSelector as ReturnType + +const anthropicModels = { + "claude-opus-4-20250514": { + maxTokens: 32000, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + }, + "claude-sonnet-4-20250514": { + maxTokens: 32000, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + }, +} + +describe("ChatModelSelector", () => { + const defaultProps = { + title: "Select model", + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", + isLoading: false, + }) + }) + + test("renders the trigger with the current model id", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent("claude-opus-4-20250514") + }) + + test("disables the trigger when disabled prop is true", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeDisabled() + }) + + test("renders model list when opened", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + // The mocked Popover always renders content, so the models are visible. + // Use testids instead of text queries because the selected model id also + // appears in the trigger label. + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + }) + + test("selecting a model posts upsertApiConfiguration message", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + const option = screen.getByTestId("chat-model-option-claude-sonnet-4-20250514") + fireEvent.click(option) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-20250514", + }, + }) + }) + + test("supports custom model via search", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + // Type in the search box (present in the always-rendered PopoverContent) + const searchInput = screen.getByPlaceholderText("chat:searchModel") + fireEvent.change(searchInput, { target: { value: "claude-3-5-haiku" } }) + + const customOption = screen.getByTestId("chat-model-use-custom") + fireEvent.click(customOption) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-haiku", + }, + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index 9c447fe011..6c955767f8 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -17,7 +17,7 @@ for (const theme of visualThemes) { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) for ( let index = 0; - index < 10 && !(await editor.evaluate((element) => element === document.activeElement)); + index < 50 && !(await editor.evaluate((element) => element === document.activeElement)); index++ ) { await page.keyboard.press("Tab") diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png index 4de566f736bcf2ebeb288f4d8eaea9b509255c29..9bab7a3a7e705ad8db2ee66651c50392994f2613 100644 GIT binary patch literal 9949 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 literal 9166 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 diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png index f5c759fad8546da967cdbb07f93b034d78d293be..8e9c01c7fef69c4a6aa08b4c796493338c671b8d 100644 GIT binary patch literal 10270 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 literal 9606 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 G2L3Of2;d|D diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png index 465ffc0263d816e953f37275f9ceb514223f33bf..8d232cb2ab90a08f637f54576ea195ada3ad1802 100644 GIT binary patch literal 10477 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�T)Xjeo%zI=0Jw}Nu$swu0L3TL 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 L@}d>O`houi$=aJZ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png index 1f238a4619657763f15f05e20656ec0f08a03eb9..b6976d6cff55dab62a17382b98838d18e49ba732 100644 GIT binary patch literal 9973 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

b3tsdW2#AyUx(s?ztAX#V`YOaQpN7 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 diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png index fad9aa30b923ada5cbd0e6c1f1a5df83319b1d39..6c90b6f6102d1593f3dd15421757624b6cc94313 100644 GIT binary patch literal 9945 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 CivmCZ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png index f5c759fad8546da967cdbb07f93b034d78d293be..8e9c01c7fef69c4a6aa08b4c796493338c671b8d 100644 GIT binary patch literal 10270 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 literal 9606 zcmbuFWmFu&*XD`g?(P!Y-I75f-+K-nN?qESh2C^S5LhJ}nK7<(I1{_rEYe67yc`zqmy7nb5LZ-O~cXmTA#U&3*IQDFj$TH@#ML5+<*n_cshf2W?YcDCY<|MATtWPrVB}&JuEO$4vIZ3pj zhg2DwM0`D%_2X$iQS_*f5D0rzp7EajyDY3dN!#G28!_8`*^qtt+gk9j)Y&4?R7SX; z0=U&%cI983ct$S?c+(Ri?%X$v^Ibg(8q4kOI22g;sGH$%_bxh zw^^;JLaoF&m+vr%YRlDok!gCgl{2wJF!fD6+e)P5& z{sm%3*oL1%$pr0(M@KnW-g^cG23k)Ssdo52jH?);6}O*p1Y>i|w|~D&=eI#cLsJ-Z z-0TUTDgBZ#0w))?rwMlK1pXHvA3rL7ttaS7$fotK-s|!o$(kBwOvZ~6?HY3`pOg0s zSf22WACPD=NZ+?P{p$+9he!+<=TxDxAZ5s@%RT%QhAwo@>+uTGoX%r5xc2nu?h>Kr zd$T?ADB0q6$Wu7f6@pgL1WT{CTY9_%o`z*O40=ohfAR3}L=olEBVm#|=2#ppH|e*y z@o(C`te_zGQKPA;u70!&!YCAzR)~lg+^|R;eBbDIyH{9STRWAo@`2KGD=(76*kmA{ z!O-ua?({7rhJsAOr&0LT=so)C`tZIr&{Ni@p=iNXj|H<72F7eyPRmlQ)l@8v)Tr=I zCY9g)X*g3h{G=O8@^+UFdd+7wxi#~}kbkp`o|l(bKtO!NN(` z`|2PZ3})IsACv63m{dSHuMxMJq!;;^FB=_0E_^n?l*6XqTx$Y}xiTqkJUKa$e1w}^ z>`t_}ANTpwCB~fN%XXmPF%GA42kEqLgp#SLDftp0*Iext=GZS(7igA#_PbkJ1A#!Z z<@!TMj}Lc#z^KDUGS)nIvbH>do};0n3Ne`}C@QYBdb*gJ(kndyo$DVM=p^G|FVU%E z$*H$ph@XnzfPy8>vF#UY_t+bjH(f)s#l1yV`2%}N!osQ&-@~kDO5%k49(~BYe0?7c zAJ2#m=$ps3MZW#JnALSq{k64adSnJ0PE%D?&5Z4GL7q8#ygSD8f=>OE`T+#i;_>lq z{X_HG_v|AIq8E{|50d~pQ}^y{n% z_QaHIry3ir%v;0=O!}faVN)U+aEFeE&js>HtQD=*iWve|WS$!#L@|`&gQ?O|>nwTs z`G1hWCdEqzZC*~W*+04oT_)WN9z0ertwm{Q0+8wqJA7;RR@=NS#|t!4}5`a&_t!bB#b_hsdYs|Vo;G7N`tfOtWw-Z>;rEbVJSKT#yj$&OgRGg! z?ed)}x`>Fs$NELOL~Q4|3VDT+t$|bzLc%(lEI^CUYjQSY)e6Ao3TV8X)m2qqCO1!> zRC@#h$kKju&F{P=XS*kbB`!^Z&GRcK|EOt)k|j9O?P!^VeDV+ z;QZ{&z-y18)ML)T+YT6lY_EUJCA(Bl@E=3BYQfFooAg=w3?8Yx3rt;nI*32K_nAXLf&^76L&xDC0g@7ZWDDs zs&zgiKB-kf2z1_+Sef+Ik&W3NO5r`@)*#m9i&`{S`T5U--V&1%Y&Mkqa5lv0a|q+<>02mNy0( zL}%CA%*CiCy3CeoC!jZ)@Fkvq-Cgy)vuo7?l5Y2;QD>R(Dmj4;k3MfM_#iT;6LU4$ z$bSQMNA48+6(#M-Izr6Pz)@4+41zUXrTPlw7jWFcK#dGk!~I9Tg{E!GFU zR7>4ta`&V@$3h*rh`epx`<+pid8D9Qs6;^;SZ|_dN?f_m%wW+d_(w5%X(ChFON42*#=$tsf+cmZQjdMCziyyLOl>4m`%|||xOKr2xiHYqM zmjNjb`~*JHqs1U_qTK(c#)mWo-NR>IAEEAh$R zC!I*)(c}_7sbTCKlc?=2uZv|ugi#PlOq583x`8=9+}EGoN%~}xu1J&4(!^L5_qc8piWZk`sJi3Ub4-1-8*{mQc`M}U+3fQ!G|pOq z15|}SqiMW9J_PPN*9@08?la~1#NWg|{EJGAYMkYOZg&PD?M=@6k*1(97*TOR#CZz& z@{BvRMuQ;dwlaTr{{$;5M6h}h~;qSfF##fQ+f<4Q^9c__+Oc45R3$wC+mE z6<%p&s*t&wd@88wrL8P=1vMum1{(EJkmXj1vE@9N z$UMAlF_Nx1SsaGucX!xO#7-H@LtQZ|zG!G(Y4E0(Ws!4`{e)R?lpvb@cPN2bem=%z zS{I%bO&EBUfHOSfqu1$96i%QedU?$+ska09V2N9xAgiTaNv9C(^Guv-94<95X$64m z-H15!`fur_;)Y%o)86f~4yz7o=?qT{adr$RZkLpn=Vfr4@<>++bq4T>v?v+5n?%0U z<_Lg{k5De*<3qu|&Z^6sfHG$i4v*Af9quW*Z^V)o=w=f>I$U z?(kt5b0yy1Tu~_1vP7Z;jT2Nl-b>|T`W?*e&%iyu(&MtD^2H5uG&=vvjWjw3|f@6_L*j{ zY!)}p-W|n}ejTTcBk1BuCbaO^6clg=6T#lupms@xq%<&ML}7S1dRLAvQVWa}en!rI zSvfQvOXe;n{IjMvl1TRz`)VMArH$p!``|rd^1OneWrh zu8CbCzj--l2tHL-R4iDPvzq#ifeWjq=hz?ImI~Ft@yX@V5WNnYa=pi2sip0GopKnS z{{igwpMlKltEO~$(+BFKu_kcPoebb(NKT8_H8Kk=Qd6+8RYzqdx3WF9G+8>Vq9a5& z>UerQg+lV1YV$9R;ebxE_(Mr|EoL?A6PDmY6N*u|5uqZ4#XdBI%vU!UU$H&zNm+T5 zIp~G>HkDr!HvkPQ{<7}lrpbJr`AB*dxu%$Iv5UI{HvW{2^e7gXbPVU>+Aw0Zy(d#d z`c}K$C9~heGc~-ijO~2wID^(mNjzAEu01tif}0{uiZz@wWAg8MzQ3zjp(`j&E_JGh z>gTWEI@?4q#6H~VK~>GBH`5W>4s8^>xS)U2{?P8u)@%m(i~dth-V~q?#|||+2e(L# zDqV_x1vdQYMf10L$zkV0Ns#RTblcCl&|w9e0Pnoy%+>9@D>)|}O*eJl#gMZUg+hY% zfDeJh2#UkxL>7nD3C8tWL{3lS+4c0v(zeGt4|0tm*j;{=zNV-r;v9t~X7_Q-4#*9@ zrP9bWdj8~s^+xwt4fFD5SqF)9f<9vO7iuP};BN~mg|Nm>#&`}!xhX?ky zwzq{Jf4ZC85g2oA2l!H`Ak)Yj-CZ4aJl@(E8ylx^=$9oXv3{|iuk4(ERI-wnk2qz2 ze)-$1uCA{BemR>ZwZn^xi~IX~z^O3N(`yu~^}?}3ZaRQF z9Q5Dv#jv{V792~ro3|`1B~m{yXG%2b%+*@l z?4T}N&-s!D$jiJ%3^IXQjU_e5mKw$@3L+wiy|c5kt84A4@bhFjDoRTS=O4S>CVp*T zW_r$=jZZ)`+Oc|oa$ib57I+MPw)A>`15H7oz)LfCsKtL$k)NBJyN`21m*M|6&Hm54 zW;3fkM=|5t7R!m>z!Ou=rt!mvZP z!TyhK112ge&vx{G!X3|(i7YC5+v+x!Esm1$DM#V~@P|==>ni|!osp5z@B8OP5sU_s zlET?tsIj11R7lr|_^RjO?)vHee5}TN z1XPWLMY&vOGuPNC1l9e|M3GLDsMePXh>UJ-0o~O~H~^N9Fo%--^VGkAfT?ms-R*%v zi#pUzER!uaXcLi;@C9V8*Yirgj|0YUdwZK^oK80CugzSA;S(SqW{Dhu)#;}VScxa4 zOd)P@af*~b6|KNXbxZ+9A6VCv;$HNOjHHtP!95(6?d(?fxZhpW0O<2S?tVAI;au5$=Z1f+lJV>8m9wA8 zPBwoFrl3NE0B}9#?o@$H?Dj$AKlhJ)T7a8YAOax9ppjE9-1B&l5goGET-G^3wY4mpZv$}K4zTK&KHnzFFbTkhU`H2V2_ za)np+LeVN7e}%D0UQN^RNn3y>_=G_l=}bFXuVueqYRx=`JkibW?$Mq6 z`T-OwcxPOY>HprMyCb=ejx8$Egj}}&{qe5BBIkY~r}s%Cq51Tch~V)z1MMd{OV1T& z1Ok3X;b?inE(H&T#+=)osJy%`GOxM~?Ft++vzM`L!XG}{t@3_bZI($}qUsRHpT6Jr z7G{dZe~RU7#j=fBG%IFLi5Ld|O4|NK+P`ejuO7M1u_&o`o$0Vq?0Evxk--ke$E zfO@A6la?Os<~(5R%_LC==3jl$r%WWy&_Ff8TsbRO!ANPFS5 zJfUxI1Ocncs)@k-*M;b#Y@#C+ z-Y;$r8OAJxD~p1g?ee$N74}WuEs4P8q>H_QylmYGAo#`#Q&j_eeEfsOI-ARXGd8nj zMnU?x*9QyGREX6a##W}pcQ1K)c_470SNK#^R7ODUTu(dlm zIRThM#%`q8-x}|06M&sowm!b$J4_xQ2Lw+pi%%gTtJFxG&~mZH8oU`{v;$PZCeJ+CG8gaQ58Q1}fr>b%uoTG8J8rNRK1mYA3r##=ot zEsy>_V2A1eq$HrL-1jCK`1zCW%~Ty}>CuQdTq_pc0A@!l;B~RPp`dtv^kp@Ry0*jS z{GZNUp~D{^Bh>G45ZcD~J9 zFo9GZQLE7;LZffEfX8XG2S_0FW^& z+VYP8P|GIRFpvXpwEgjRKU^9R40I!64rL^`UgoM!=dcllWVZL>mLtz=VLP~1!oIqZ zdq)G%vZ7=ZqHbHZN@nv$`Jeyj^fAHzWh6i_du=Jd9M9huRpxaqvl}7tbk`6;(y|ok z1ncGA7J1Q@e(lr^Sty-QEQG2YI<()wB%f}Si@&6D8G$}h0Y!W`5#w{_n%~oRC{6p* z;~jwds29Yvq(af(yC^j8PUL@hW}#}DWV1j)^f)yZ2*skZZ`;^nRymC1Xzp(x${!;i zZ?o>+$?<#K_(7%i7|)Z~cJ_dj-7H|Yxa>Nmc=emR%kbyix`}<$HsL0{B_E=FPZ$no z+XXJ!L0?bLGN8*%-`)*N2nsfMzyMWuz3L4P2%r=PNcH9iCuAg2!wotJ1wL-Nw^$k1 zwbp#7WMJtuN_nCOpSwd(l@!i5-mCH~O?>jtZAvYH!yj?DFF(ycmGYodJnR7VQ+y}W zW&Yt5LX{97-xo!4elew7;})e=!{`W&7Q~&|oC3-xwB@*6K|w)^VF9Dc>A3CMIM1q; zDcd7Yr_P$ZOPGYqp>YFoSjax5#%{jyC!iY3a5_%=$ci=+lqL3ecPXnNns4!@i>Sg;pO_Qn9gU^)x*}a$|nnF z{rU6fifv+|(bd7i@8R4IF(sw&sI?2dp@pvu1L_6x1Q7HnY%x&Z7kIxDQK+}`@LGqrpWG#s-2u7y`)XY1gFECKhSj)u>)|0|Z9G)zA_q9zA&ys@)EAj?wauuWD9&?+*K^tasnf)K}KFu$5p~l9y*98k?G>$X3DP`)1XI zg@t31o+ds}sdg_5ZP58NLp>wbgxnQnFo?PQPv&^X#9X%e<2IRxQaCNxOu0;yx~$2_2MyK*{dufH)_sV;Uc%$N=-U;S; zFfPtcw~NdyHqhUT#@QO1n!5XIv5#?icFx5o2plXbL{Be3)8v(vSTca3saspJoNMr> zHfm54FNoG8e$zO<1TjRm_jSkz(cZ?1H(ogWpbt9r2V;`KegD`w$n^iFSHb;HHgu!y zXi_;8hlwhJquC7*r89dCGw+(e?WdkOI%8@#IWtUxxjXKTT9hwCeyWG`$u?yUuBW~z z3`ehuJp=S^CBP$np)S~pww&KIlZWvMk}N(MoAr5F%wjB0Yktv}LHcWtCk+x0r?6XY zoJe$pMt%Im4}Z(gKNFEepxilKGb;@b8^A*y3JUlMqX+B|t%!0Qv78X<(6GAG?l?^- zVU(oAqCv?fgcL_*ym0q8YLmf?x>gRcI*k`WN~(j}wQm@Vk*%p-4rkH8#g|TD6>+0> zILB1aHyUprmOr&(eV8u8p^7w3|;r-j7pXkazs%R&4HuD_{f8F26;IXRSt}P9r16uJsiT@vwd@282fl)=B0Ie7-@KEJ6<@ zTH1v(cSoJ_#Ngnvnr|}lMt3EKEnqEa&{bi6$l08Jy^MAVmabO(8?lFjN4F_cOFE^N zBB&&)1HomZHQ3k4^`#mn?M9TNHuBM#epx*GWQ5VNMi;ai6DLIzOx zB=KonMp2g9-ZrF5K9Sl&3B2T#3xggYN|S864`dK@%EIUGR@^2gDMtR5C^X;1g2l0n z)s_prvDO~T_QRnxD1X2XnK9&^FYU z=~U1ctzND8)j&rwyioN;gF67NbV(P_f^p^7_?43L9*&3WGvWSy(>t`D8m*t#Fg#8Y zQSY#(gjz1f$~rVp$d!fK=K?kVc*5rxVWZ29CwMc!<+`g<8erdc`I$bxvYP+QspBe1 ztMm*d#Kh{4v@`DQ2Q8ounX_6O-uHE$sa%M+*6_;_DOY{Xco$L71gMJ){#f_p)vR5C zq`q(57#DW`GMg|HUO_6DTf{!U#un27=ph^q1*>YdQaGXS+p!AFGZe|`VK!0gk|>8E z=XS*~vu)%qiivRpTCe#SUvLLof~UQ!@Yu;j0PVUQz1Vvq{DA*}Qa4Gv(dEO%eK8Hq z21KBovqY~{ZRhEHh@sFT;-!>8~XZ=0LPCj+kZVsU60I4 z7WTGe=zf>8v~gD8CyM-IKOID1Kghe6#-9{>1HWEuRI`RdD`|^8Qo)Milbf`;gy3e0buGjn|m2xUGOca}e z(IpQTh+_JyD*Qc?qNdjbn%d=Yd=XD<-n&%w`GeTgzQ5I6YZL4ocNJl1DW${oBK$j9 z5HtfwbLW%0wX4Um-IKr-&zlLHB&FbK2lczQlxX+dQiBtoA>*;>xm67jF zEoKhYc1r><|6W7=vxdCnwzEU**A{kDPI4UCYVC<%^3j;I*n8ftqbzS3Aw!%b?@zja z+kPzxTTgnpLn`yZy=A>z(N@M;y7SBtIl>br13TnU&F0#4)@z4gG|Sd0D$9Fu)p8ws zsCtZt=gZJc^x0@ROeeh|ZEUo@V;Tm1qE@*YZ0Ppi;%i{frhSdgL;^$x)X4!`Fr}(k z-^fdT(5K;F2Ro+81A)(8e<(F4+=`@w>X_MwWts7V^ zLaO)tAHW^(w=9VLtdZggjEc8_*w8np#`c<06wHdR#Fg%qz!Ro_`^DNk&oPQ*mJCUv z`@D;S2(L~MatY=Lq0j2}V?@w88W2$Phf?pT^gwOBjWH`{3Qpv$T zj%Y*w8Y@epH%PZb%ekbod=@B@0|`tGfHL{D&X0rJDp2y>D@6zoko}7PvhUwaD^*>7 zdSko|6Ia|%YCK#2(%@5F^i3gZM6>^YTZ=@rE5oESd`ovQrFbk1kgoDcEZP-`#7k)O zf34|l_J}>HyzS7&46FTiVCpcl^oU--V6)|LA5^`=W6FGBVUM+~WOamgu22q{QnOTj(dL}d%7s}1K}-5|;)80~f1&~@^m zOmuEh&_i=kz!0-$HTEjBm(%xr(9pAix|PcaR-&ovhP1neYIGScWd1Zd;^p?Xw1K5U z7c(~xk)u#`0OS!gzdGiv3IGw12YGOd5A8m1fJTYzf8?J3Cy;s}>j<*F01oB=r#_H7 zEOy$Z!e{b=rqpp$%CBd01c(4y_CC36X`w__%`szIXTRZ?_xXfQ_ORW;&hHsak#xgY zvGux@H3{JO3kWVabJt$Elkp>vt_!r*R5-T20gVRQm4ua8^!Z+_o6*qJORjNo<$JE3 zt5;A;i~@Y%L_y{71<45K@pR&0Y+;UX`cs`{s^uq@9V05u2xM``UCZsFlfk5myIX)Z zWFIWMTh3OqtM8Z_+^$=I4W^o&Wc(gGTXXaBXuW^m2jV$IWwm`h#3bZ!-ys(OA{_+x zSWo;RWvC+cT+2NVkDZ@-B0@lTdMd6#JOEBpAwF-HuMmoNE1AAZ7P0_W5I)K%Ntc7Z G2L3Of2;d|D diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png index 465ffc0263d816e953f37275f9ceb514223f33bf..8d232cb2ab90a08f637f54576ea195ada3ad1802 100644 GIT binary patch literal 10477 zcmbWdWl$Vl^zIA6gWCW>1_=ZR?(PHf(Do165J&?1UsF)_g{6Z z?)`Ai`9cldJ+r&l-fOMr`Rxvumz6+$`|d3i6cp-bNijtzDCh&=a~k3s;IAdGKqM3t z2GnOUVP&_>lPm-`<$>j)57BWLqy-8CL7*~NQM?ZPh=@FDwcZ#!*!~ziOJ-knTQb&G z1@j!oCacjP0i=i`58KJjAHYwiDV?JryOTocCYQkDwpTun)PKK1`KZcpcE89sxYnPC=mllTX4x& zfxEK_L2M>P49Fn+PLCL{Vd7Rkvl9dA>TA0XMeqoQb@Ol^Atyr=5vLOD-uO8X&{vF1 zRoPxc%AlF_u8?vS;`NhXA?a6J>2k&>D_Z8riap)A*e8GBeo9(?qWref9%|RGS=jNJ zgZY?~(@4+CE0+XcNWPj?eH&i2zt+`do5iV=M2h%533~6a?!LRr>!$uDjrF|xY+tE` zozmCapvr-x5UVQ;<;Fs{dj_U{_V2~GKm629+c`>B*(#Ky4EOPttCP-oJIm*MM6Ila zF{;eYGSYl58*B0pAG*&b6=VkGde1V{iTxW)- zr11WYoCsXc$_g(FOP$GhItd902P+N;L?Y*KGOoXb9RLB7|gE293vL4oJ%St2d(n>loW`)>}kNwg4;+a*&7616*0p})H(>N~cB z1zlfA%5XNn=iAqm!Dzzr(L&erbwW;?4!4V~WO~hPuglK}lNL*Lls2Wba3!Blmm5#_ z4_BIP9USVgs@^YFeWbp(Y(Fb2uWFlvMF6>{B8$T*erdKD*sTitn^ZqHzXcPJW)2E;`bNsu{fxxw5} zhb!{EhMxkr=|tx3=?bB#_rq>Vxk_c8P?UipkLy_!W(01x&-0%j2_ICeb+RHEwA+3T z4TZAAb59U++35lIoIxCdWr#Li4|}}l z9SO6M7K?dUs4cy}d|$m6YxG0W!Fvw2-Osntoa<)${VTTJ#JR)$%*x$da_I-}@EM98G+pZ{*$(YFCJm1gpohR;HJXomC@riUC ziXyjgKjo)vPogkPLx+*YOeUGT6J=K zTt0%Pip*&ctsX)2dNBk>=lks^+n-#jR2(B<@}0?_TTkU@i2}?`zc9y`V`3uGSd?~+ zp0I%d`JT0+;#ckK0kP27X^Lqi05TR&dXy%G<-p#nv+KB zm1LOJ;M7$7K|=aL%7%(#{l3e=wRnF>TrvT_M}rUYvv{5QCcg3r4ytC86+x%;53S?& z^DaZTIRE(4p%2?%s(Di3_9t^x10L%g_b1}w;(B_7&=SqvO$^rUhlq(vSiWzUvLk_A zqva>IpH>>ny0nbw9Dtcobo?U?87?rSo#X9Xq?_F`v7T!D(Cc z#&ykkAqLD=^r!$TmeBSlQfIB=gqDbYL=2y`Lo77tq9UCiQtfs*9 zE2=yrMGuWN?Q(l->zes80@3GU0I%Kkd@x;!sy#IrS}Ac>p&XTvL(e;h&z*&Z1#2`B zWihZrTr3q{`0SV0l}x@}&|n+YUTQNga-S4qX8{7y*ZyU{amkOEqq8H!Z@-Y1164XL zEe*6ws@U4vI_MyErNK$(VVX5uNf1oxvRF`23JPAKL!IU2<$VnQkS$*{%tb3_P}|7w z_M3E953G+k8x_T@5k)hEVNi`UtA$Oo@`{`nRTm?N!{<*W2+yDzqhAXn}$xM zsBm6F&wWaqEQ}|(+05p2oh8(n+%X%wQc5(v(E`Tgoj=@|W?9gz9}!dGv=&aL(**(z+8>p5#yzs!OA5mEBWo)u-Kiks|Q=Wlhz@%?knho!) z-gJ_@Yre70oxBgGaV|W>5@KatJEWd~;mUS5fTwFDNm|N=c|nd9{RWy>2wW3z6iCeL zQuBQimtLnM6)x84>`%M|$!7P25V9Tj_R&s1V}CIL&|Ui|=;%<;{fzw@`&)=}zxQPG zl@0t{>GI}fbaZgQr}u!F9cWQ*A-KTA#0-mMQP+_k@I3FtTli``UZZ%SIQNV2nJf3t z4;O^K&yq|6bdGyKbrzn zYwX}Z7$@lV7J2;*gbUdJdr8ZsI9a+i)|L4Na7P46QnzgJ1; zVv%O?pM6fC%B7=@cb0Kt>!WID5`RkqRqM%CRibpjRVBWIm&Jl*%~3Hfc9r+LTn$dW z^+xK%ESXKwDq9kJu3S~_!^|7B`Q%f}Es=)zDqc=bY~1FkCs4F<#jLccwxHqzO7*mm z905|k0gV>(S*q&=K0(!?!Dcj_dM_49H788_HN1I!^4`kcXJamXNKM-@4q5~5frr++ zZ7L2CtBbVm2cWzwfM=G-e#Y=6eBsRvqhO6TgCBCM5JRVnAdd@hHjE!`crW?gC7Gp_ z8($`jQFVv*k2@m@B=2oMk(CO1A4Y3~&lbWg{vAQ(rgqan!*Jcs2AFwrT4}510PTx{ zG7_DtB!|Zr)bI$i!}ni2XJt5P66h2mkq9SEHftmNN5#7>Z)=HR)4wJpBoKVmijn92 z2#$x%V5e!S9P9mUsxWPXfX6ae2tDB7e0w&AZOvg-TPb4~(q;1gfN;ogV?B}oFUXoPSYH*1Xu7BH}0TWPqF@ed|pt*8*iEJb0+<4*{7b>TqgpC#+J0S zkWx5Ua7{>!45BespK*i%_Tgqs&t^&9Not%V8Ap-00yS?&X3LMDj`5rx(PIVXCl9t5 zH-;h*nL+@Hw8n{+7g3c#amf9IB>Yzk8v)0tS(#9MYyAK+A~D1888m#R4M6{yLDvoQ z6jUnY!#;Zjz|Y3Ed{DT&zIHMZ2#{hUQia)vNg!xUXEA{-;oD%grMt(%m#`d^y+|x@tYM0Y>xnAgi@83@!+IBC0Me z$CY_d;d9~|A8~U^BW5FuoT(#7>`i~rz20mmN*2ZXWqDk|<9x2u=)eJOo zT=|Cy>I92>TV)S7^p@1BIWSZ=y`v&a5xO474$mfQ3em#;J@@QjR%85bA`-9}~Pd=PTLUx7%6 z+NDb_L%n#v{l83C*k7a3Lh%3R$NrB_iCo-2K4!d5@hVqpu#=JsCBa>u`T=%j0E2{N z9q6MwJ4V~C1}hEb`Mv=_r?2GbhV53y+yuc2Q zo+_;vNJ)TOTnv$v1j-HqAT_huE$7Q1#>@5cxDI6#s?`1gr&vW>XaE z4{pc3Om0W`IjYP~^VuT6U0{kFuC}t4S-+YFNTFl?^;#=>TKQ{4VxKSqy=00(XZdthCtVd3M4 zS)4D`OO!XM*!9&>-DahykBkCAH!(7z{(pjyQBgtX*uJu*9k5mwh4FVrYu@wo6BpXd z7NHISVQf@6trHK@1ONcM+7fWrvtSq1U6lVqk}2S=ktFr+(P247|6Mfx?3XJ>A?&9Q^(}E!4uw!cxhkj7^Ur7Y(wmg^#U%%AQr0^w0LIz8Co@1qDo;&5k5Mu47^{ zd)@yw&GB{t3icK#Yf37aM3djC{GWTid@$UQ=P*|g)q8_gH83ADg z0|EUTYVj|7prSI_{2ojsAubMB#@JUH8XBb{X_~7cXz{ zce=vqaIRib+UZy#hijJUTjy$JwIw&v5&{v_ze+(mvkZWp_PW1!@Om z;=h&EtAbsoMV^z111tQ$vqbihU449n$D!2G0sf*~S(feo05`J4rK_(rf!9z)5gYoK zvXt-$3{C8RG~M=7af_s++39H|}W{f@q1z-|Jnx2Fe! zQ0KFnRr$qVaY=lPUS8LBVq)Y@+ME7le3#(j!&P(PPWG_f%Y4~ia+j+Dk#nI4w~Rf+ zWpeoOaNcCHpA>VFG`|hTbC|o1PCLXy4$;{MlkDIgogeN}P9nYykdOZ{$;jngOL`&w zvh6rI5&IEWQ&XvJ@|1otX*}mIs>=*j>;3;Y=7pjR(=*j2_KS2vw)_Tq#<5c3dF zZZD7*nXEgq_;z=a^AS)kF3JtM%QPB^6$(ERNN9x1PV#x09J}Ux+^n-RYDvxC3Mn0=#2vZDHeVKs9WChKwbu7dwU5AYTNaIp`#U?-z0j$d(l>eQ)-%sd{ix-M@+>Fl3sP`d zl>k8ah@HCF@jf2siVwtiDi1IwAo0%DJ0B+nUw2*sD_tjT_YM2*Y|Rs>T;#_iyIj7l zdMP1pIb~{@vSH{kVRT1p4$hs`G{|F2K>gQeas>3M2{#qj($R_Y? zZs$+WXMUD!Idjy4KLQCpo7q$V9J0&lviJ*7Bn?LU zmCu(O%f06~)as0WthxT;2RitZ*Z!-Xh(ZgSkSGO*rF{F{+y_CYVy zjG$2yfKiFP{v~b5crTBQ3uXtOu}2Di@_XKjg#qolg7ZTpeZm+}uocim_l~Q(edcHv zf+w`zudeGH-J+?^A)-W8q&IzJE|wYtIj|P~hr-IK3YWR!sTuujZ4p6k%n3$V-djUB*XZ%+dQ1DD8plMD+C3`!LJ1~ZxTzZYs? z%xiD3JUu@zT{N3Z!@F#hX90U5zkxRrX#%QadR|B$YJ%( zTQ-xcm#JsVB%sC@Xr$XQ#JpdldGv6274_WQxHt-(&4Tl6Ixaq;B{S;EI<4fP-N<01 zDKPYk$2ba6I_pLv69>kWbs(!Ec#4PYNGyz(A12HXv#vinY4N{C?YASaRU2<3Ov<0O zpS6_($Z|j@z+LU9CSaWvVtNxjcau~T6MFo=NJ{Eq2O>R;GIoD!<*0W)TV0x*MDNR0 zn^P%D6Y@b5cs!{uukO-FLSuV0wn|w!44By~EzUlg9l9mK^Kk;Q19%j(_?SqUg$ho8 z`l3vPGV3l$LsTMehyE^$zBn?`7UY7;5|qCI>Epj!4b1KWfbt-z>ID2saFaKXS%_bq z4yOD|5IGQz`we6H2S;a%G59Kc7Q{@(+15|r!(}TYzhc3m^eSmh2 zPw{706rI2S{rlS>ck!(X^?HGqd&7CiGlNomuZ-Yf3VzhRJTe{w3K9|-S|J$81KI|mev{6s6L;zgg6;QP=(-Pp?(#lMKXG063 z(#fe@QFE7ub*{+9r&_oYIUrSeYpFquXl!nl^Ts-q@@d_4p8`o2wm<;tZZ+}rdODAp zJ?7^=0NOto0~$L@2Z0y49Ci|io1D?s={s zqkl?kn%}sBIS2oVy&Msf$vS_-2|IbtcG}=iE1My{<7gDb228Us@8|EFGR;dN5mR6v z=kSSl%_u7XG`mNa3~Ui(G8@99iolydFNaFzE{rs&DIz3eM5$j+!4228P7a|oa;4y= zFJI=A;|FSS6D|W_f0=U9P*164c6K&Alu4r|8F2t`rSs%4AU~4Vs$K}lRbOArYbK{1 z&B$#ZJUqes)!lxuLlBLK$A>|xu$XG!lt?fm&2{?d!oY;?n6Q^>72En9b;6sxJ=axx z1P}s!*zdy@+IKP;Tb!q>t@Xf$)x2|$Y%hK<8N}xn?FNJ9$YZe1|C1o zuP%&sbpl_o{DNolbzP{^7E?fs0dfopZ#~SF7dZ$;G_w>bFe4iILWm zX&II?jVv+wEBA^+*pQcf^J>iLW9c7fS!8l}tySuo_;xyEo|k z!_#_J=a&FOQ7&CqLQMX%*Tf5iNyC&Yt$=g#Ch`0I?~j%+uTqQ_QIS2;4EKcjXtK&V z!%$F&5r5wc*mF;{TFh0;2lgjT34@#mp0p7tH&|-^M!k<{9_$G9JWX~}tqfVE0NTWuV=_hF2u71Fw-eey$HtOlf@ zK!PlhrJEMxN>oI|sTGpUI_G1R3~duHeGqCnMbJErhc8_H&wDQrO&Br30lUhZ4mATz zE_Xaeou33ug@;P{27d!B#G8*E2aC`SLT_&<(~U(g5kVp%IiXG@v9fu)(& z0a%?@%)NOghQ@Gz8R49z&Waf@w?5N!<2Cpb^bkJH70Z!wmK8 z6yV^M$9mbQu}Qtc(mBw%#XO+G>?gBKeIE;a&Vh6jG(}}Y9!2oHVGMT<`#iHi=jH@z zUWge6bDXM-UALNb0qGJE(~#O&>O&hQ;+i7MyjE>v<5EaN$QhS6eFHo$Mbx)Dk{^H_ z_r3qEzpbqj2Ec90z{GWPK*9ooB{88U%#!fpEKdmMx9YY0hfiYI({yUMBkvKGkrjN= z4V@BF!@Df=6qPfekU0m!M^sXvp>>TToPoVy1;J6Pth`)K`|#__@H18>t{z;{nelgi zQeRA(@A#8xVDOL0Z_U2y@SHCc+k^?wH%99Qxfq$o z_9n5&q3O5*-ne6;6@pedasm%QP=;=x9-1@ z7H`=&Nu&@5yTr8!Q3rm12ryz5WHIHl5jG2G&$m8Q$HC5sr z=_ER*&?w#Gs!LRaN6RBdo5UNaiB}X^(M&fDV-uHwbA*b*Pq8vLQ}Gdx-?}qBh3jTK zQgci?6a1h`WbL7=Sue)n0R&R5k@vBUYqXS(-eLo5q(uC z)GwKg*~v8b)oNkj-0(kw94n8?37(%Saoawc-#D0Zba31OdbXqr#M01Vorl1bm_qe+pc{e@;08j`zCOsKq^o;X|T zZ~Odi-Tpxxdm_i3;^w5qu~?AH`)nvv@qsQJ{&=i+GiKVfa(OqQrKKgJ?0X8dfV#T( zc+sex7d|1zdSeZ2rPAuVt@mqf&V5%xJkh5hRK{?L?H#iI6t+_P4a6F*?3}Ryf#P#5 zt#`CE)XGvWCZ3)i6^oCmQI91fyy+Rm4IfSq&Z7Ygf}|$Mbd)F|If~xDi#T788X~kZ z*0TGjGZRO197J34 zBTXZo)bg`5UHE<2Pat!hT`T2dwX<4I9=k`vm;6eU{V#UTu(xqtOqf_$-$Bg-?XX+n z^5s$p-LMo{YnT=1c?MKsk~NJA*3X61Rs9d&vyy8B0S0l|qz3Uvj4=C}EWcB4uzQpo zNI`z84Dp4OdexUxEZS1@@N?zmMLRAS`nKp`6-!g^`@2kLaK?5zHKj9Zwe%E~$4DPd zrq>c4?(Yw|c&FiuPT-w+kNUnnCsOK#cj{x{zhlr;{@D)vGNIOj;jd~z(2b3(ESkR{ zrj3N&=%}oa`+0RFIqfPyIBr8N(*n7>fmZeB<`@AU9!r$drws+pG-qQto}w%Yc=6{$ zg{DsZ1N$@#ylpnOI0SJSjVL|KetyDsH8Dri=COyV%%kN%*F?DGS$eqna5RymxjGl_EU~JNnTi-Pa)t$_d$N&-Of)K zK3ahc{PmN?L$W#YRxd0ayHb^9FFUd}HdU$X^B82k^Pd`t)mLc$2h1R2oX%j`ka}P& zb)xetf7flH{J3y78ceGH@dnZShhq%n$W zZ{`f{Unt&t*zyO43m{S=KmKh7{O>@$f9ROA?@DC(^XoT^zweVJdOih;GG>0c;cN9( zHQNROyiJ(;E{NSc?HzdE-RthQ$7A!qV?SGHw-(PTNpu8~Lg?0*OFfnTecv`)%UjPt z8oT;?z>_1*XJW9Y$?M3#zpqrU^4wl6G73sdh23q5FXwt^uL}@^aZms5TohjuJi0$! zia)Bp+&m8G@X(xY=%AxJ|H2d8H8D}C(%|1W_++yu{%;84m)>0Ge4ZfJm{!Y~Ra9M{ z5(pvR7^}W(Gx2Y+N|1t;mI~5;@9ZtvZz@|@c&%Qnt#J6TmQJ0hhQFdiLA}1_BwFkO gFD=6VRV6S`84h&Uh|MQDz!9j=;<92@B6P@9!2eX(x4^Fz4}a8~Hz;qU zM1@q{(~mM>+*LsjUZ;-o_(ke@y%$_!jbQz{fmFaGcW%4^PLSF`7PeD%|Pr#zwQ#N%QUxuc6zY zr(U+Eyoor3Y*u3L@n*7YMD3#*_yfA6Q6PnixND}y(z)tRrvr4C+V6iB5w9hlm~z7t zaW<_DpW1w9+V{SgU*Mwif8BwvQy**;#Jsp@!1wsf`$9zy8%WGa9yUvRdUJP)pGQg2 zggW=KbU~2$-n3|SMv-*W0T(fJFB8JSjP;fLcQi<$CiYHV-K4YKa!ihboQNRVd;JdoI`s0Y!stkf+c-y>gE7U7jH+sU7jEdhzwd%IIHerLGb_HmB z2Rp#er^%l^BZB7V=Ydd;4LYo7J*o72F0G+qvz&I{2*H{L*|dE4evd+WJ-^?f;$CdVbvXM^_L` zVP4*+4XwcAmd#Cp#>4OIHlAsyNVFG>SVQo6s@kYtD+spwibTMjS6G?0Y%uL4JO?p6Wweeb`B}`{X*5NR=;B_;RiK$=tw4$Mx;)>)Q@+hFRf)2j�T)Xjeo%zI=0Jw}Nu$swu0L3TL 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 L@}d>O`houi$=aJZ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png index 1f238a4619657763f15f05e20656ec0f08a03eb9..b6976d6cff55dab62a17382b98838d18e49ba732 100644 GIT binary patch literal 9973 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

b3tsdW2#AyUx(s?ztAX#V`YOaQpN7 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 diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx new file mode 100644 index 0000000000..6f19925992 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -0,0 +1,337 @@ +import React from "react" +import { act, renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types" + +import { useChatModelSelector } from "../useChatModelSelector" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { vscode } from "@/utils/vscode" + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn(), +})) + +vi.mock("@/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: vi.fn(), +})) + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +const mockUseExtensionState = useExtensionState as ReturnType +const mockUseRouterModels = useRouterModels as ReturnType +const mockPostMessage = vscode.postMessage as ReturnType + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {children} +} + +const emitMessage = (message: unknown) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +describe("useChatModelSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseRouterModels.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-opus-4-20250514" }, + routerModels: undefined, + }) + }) + + describe("static providers", () => { + it("returns static models for anthropic with apiModelId key", () => { + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.anthropic) + expect(result.current.modelIdKey).toBe("apiModelId") + expect(result.current.models).not.toBeNull() + expect(Object.keys(result.current.models!)).toContain("claude-opus-4-20250514") + expect(result.current.defaultModelId).toBeTruthy() + }) + + it("does not request message-based models for static providers", () => { + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + }) + + describe("zoo-gateway (dynamic router provider)", () => { + it("reads zoo-gateway models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { + [providerIdentifiers.zooGateway]: { + "anthropic/claude-sonnet-4": { maxTokens: 1, contextWindow: 1 }, + }, + }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.zooGateway, + zooGatewayModelId: "anthropic/claude-sonnet-4", + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("zooGatewayModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["anthropic/claude-sonnet-4"]) + }) + }) + + describe("opencode-go (dynamic router provider)", () => { + it("reads opencode-go models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.opencodeGo]: { "glm-5.2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.opencodeGo, opencodeGoModelId: "glm-5.2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("opencodeGoModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["glm-5.2"]) + }) + }) + + describe("kenari (dynamic router provider)", () => { + it("reads kenari models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.kenari]: { "glm-5-2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.kenari, kenariModelId: "glm-5-2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("kenariModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["glm-5-2"]) + }) + }) + + describe("nanogpt (dynamic router provider)", () => { + it("reads nanogpt models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.nanogpt]: { "openai/gpt-5.6-sol": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.nanogpt, nanoGptModelId: "openai/gpt-5.6-sol" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("nanoGptModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.6-sol"]) + }) + }) + + describe("openai (OpenAI compatible)", () => { + it("requests openAi models on mount when baseUrl and apiKey are set", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "requestOpenAiModels", + values: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-key", + customHeaders: {}, + openAiHeaders: {}, + }, + }) + }) + + it("uses models delivered through the openAiModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ type: "openAiModels", openAiModels: ["gpt-4o", "gpt-4o-mini"] }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + + expect(Object.keys(result.current.models!)).toEqual(expect.arrayContaining(["gpt-4o", "gpt-4o-mini"])) + expect(result.current.modelIdKey).toBe("openAiModelId") + }) + }) + + describe("router providers", () => { + it("reads openrouter models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.openrouter]: { "openai/gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4o" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("openRouterModelId") + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) + }) + + it("reads poe models from the backend-broadcast state routerModels", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.poe, poeApiKey: "test" }, + routerModels: { [providerIdentifiers.poe]: { "claude-sonnet": { maxTokens: 1, contextWindow: 1 } } }, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("apiModelId") + expect(Object.keys(result.current.models!)).toEqual(["claude-sonnet"]) + }) + + it("reads litellm models from the backend-broadcast state routerModels", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.litellm, + litellmApiKey: "test", + litellmBaseUrl: "http://x", + }, + routerModels: { [providerIdentifiers.litellm]: { "gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("litellmModelId") + expect(Object.keys(result.current.models!)).toEqual(["gpt-4o"]) + }) + }) + + describe("ollama / lmstudio / vscode-lm", () => { + it("requests ollama models on mount", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.ollama, ollamaModelId: "llama3" }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "requestOllamaModels" }) + }) + + it("uses models delivered through the ollamaModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.ollama, ollamaModelId: "llama3" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ type: "ollamaModels", ollamaModels: { llama3: { maxTokens: 1, contextWindow: 1 } } }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(result.current.modelIdKey).toBe("ollamaModelId") + expect(Object.keys(result.current.models!)).toEqual(["llama3"]) + }) + + it("requests lmstudio models on mount", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.lmstudio, lmStudioModelId: "local-model" }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "requestLmStudioModels" }) + }) + + it("requests vsCodeLm models on mount and builds model records", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "requestVsCodeLmModels" }) + + act(() => { + emitMessage({ + type: "vsCodeLmModels", + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(Object.keys(result.current.models!)).toEqual(["copilot/gpt-4o"]) + expect(result.current.modelIdKey).toBe("vsCodeLmModelSelector") + }) + }) + + describe("retired providers", () => { + it("returns empty data for retired providers", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: retiredProviderIdentifiers.groq as unknown as "openrouter" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBeUndefined() + expect(result.current.models).toBeNull() + expect(result.current.modelIdKey).toBeUndefined() + }) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts new file mode 100644 index 0000000000..07cfbbe77f --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -0,0 +1,367 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { useEvent } from "react-use" + +import { + type ProviderName, + type ProviderSettings, + type ModelInfo, + type ModelRecord, + type ExtensionMessage, + type LanguageModelChatSelector, + providerIdentifiers, + openAiModelInfoSaneDefaults, + anthropicDefaultModelId, + bedrockDefaultModelId, + deepSeekDefaultModelId, + moonshotDefaultModelId, + geminiDefaultModelId, + mistralDefaultModelId, + openAiNativeDefaultModelId, + qwenCodeDefaultModelId, + vertexDefaultModelId, + xaiDefaultModelId, + sambaNovaDefaultModelId, + internationalZAiDefaultModelId, + mainlandZAiDefaultModelId, + fireworksDefaultModelId, + friendliDefaultModelId, + minimaxDefaultModelId, + mimoDefaultModelId, + basetenDefaultModelId, + openRouterDefaultModelId, + requestyDefaultModelId, + unboundDefaultModelId, + litellmDefaultModelId, + vercelAiGatewayDefaultModelId, + zooGatewayDefaultModelId, + opencodeGoDefaultModelId, + kenariDefaultModelId, + nanoGptDefaultModelId, + kimiCodeDefaultModelId, + poeDefaultModelId, + isRetiredProvider, +} from "@roo-code/types" + +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { getStaticModelsForProvider } from "@/components/settings/utils/providerModelConfig" +import { MODELS_BY_PROVIDER } from "@/components/settings/constants" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +type ModelIdKey = keyof Pick< + ProviderSettings, + | "openRouterModelId" + | "requestyModelId" + | "unboundModelId" + | "openAiModelId" + | "litellmModelId" + | "vercelAiGatewayModelId" + | "zooGatewayModelId" + | "opencodeGoModelId" + | "kenariModelId" + | "nanoGptModelId" + | "apiModelId" + | "ollamaModelId" + | "lmStudioModelId" + | "vsCodeLmModelSelector" +> + +const STATIC_DEFAULT_MODEL_IDS: Partial> = { + [providerIdentifiers.anthropic]: anthropicDefaultModelId, + [providerIdentifiers.bedrock]: bedrockDefaultModelId, + [providerIdentifiers.deepseek]: deepSeekDefaultModelId, + [providerIdentifiers.moonshot]: moonshotDefaultModelId, + [providerIdentifiers.gemini]: geminiDefaultModelId, + [providerIdentifiers.mistral]: mistralDefaultModelId, + [providerIdentifiers.openaiNative]: openAiNativeDefaultModelId, + [providerIdentifiers.qwenCode]: qwenCodeDefaultModelId, + [providerIdentifiers.vertex]: vertexDefaultModelId, + [providerIdentifiers.xai]: xaiDefaultModelId, + [providerIdentifiers.sambanova]: sambaNovaDefaultModelId, + [providerIdentifiers.zai]: internationalZAiDefaultModelId, + [providerIdentifiers.fireworks]: fireworksDefaultModelId, + [providerIdentifiers.friendli]: friendliDefaultModelId, + [providerIdentifiers.minimax]: minimaxDefaultModelId, + [providerIdentifiers.mimo]: mimoDefaultModelId, + [providerIdentifiers.baseten]: basetenDefaultModelId, +} + +const ROUTER_DEFAULT_MODEL_IDS: Partial> = { + [providerIdentifiers.openrouter]: openRouterDefaultModelId, + [providerIdentifiers.requesty]: requestyDefaultModelId, + [providerIdentifiers.unbound]: unboundDefaultModelId, + [providerIdentifiers.litellm]: litellmDefaultModelId, + [providerIdentifiers.vercelAiGateway]: vercelAiGatewayDefaultModelId, + [providerIdentifiers.zooGateway]: zooGatewayDefaultModelId, + [providerIdentifiers.opencodeGo]: opencodeGoDefaultModelId, + [providerIdentifiers.kenari]: kenariDefaultModelId, + [providerIdentifiers.nanogpt]: nanoGptDefaultModelId, + [providerIdentifiers.kimiCode]: kimiCodeDefaultModelId, + [providerIdentifiers.poe]: poeDefaultModelId, +} + +// Providers whose model list is delivered through a dedicated message event +// (mirrors the settings page provider components). +const MESSAGE_BASED_PROVIDERS: ProviderName[] = [ + providerIdentifiers.openai, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.vscodeLm, +] + +export interface ChatModelSelectorData { + /** The provider key used to determine model source (undefined for retired providers). */ + provider: ProviderName | undefined + /** Record of available models for the current provider (null until loaded or when N/A). */ + models: Record | null + /** The configuration field key that stores the selected model for this provider. */ + modelIdKey: ModelIdKey | undefined + /** The default model id for this provider. */ + defaultModelId: string + /** Whether the model list is still loading. */ + isLoading: boolean + /** Transform a selected model id into the stored configuration value (e.g. VSCode LM selector object). */ + valueTransform?: (modelId: string) => unknown + /** Transform the stored configuration value back to a display string (e.g. VSCode LM selector). */ + displayTransform?: (value: unknown) => string +} + +/** + * Resolves the model list, storage key and defaults for the currently active + * provider so the chat input bar can render a compact model picker. + * + * The data sources mirror the settings page (`ApiOptions` and the provider + * components) so the chat selector shows the exact same models: + * - Router providers (openrouter, requesty, unbound, vercel-ai-gateway, + * zoo-gateway, opencode-go, kenari, nanogpt, kimi-code): + * react-query `useRouterModels` request. + * - litellm / poe: backend-broadcast `routerModels` from extension state. + * - openai (OpenAI compatible), ollama, lmstudio, vscode-lm: request on mount + * and listen for the corresponding `*Models` message event. + * - Static providers: `getStaticModelsForProvider`. + */ +export const useChatModelSelector = (): ChatModelSelectorData => { + const { apiConfiguration, routerModels: stateRouterModels } = useExtensionState() + + const provider = (apiConfiguration?.apiProvider || providerIdentifiers.openrouter) as ProviderName + const activeProvider = isRetiredProvider(provider) ? undefined : provider + + // Router providers are fetched through react-query (mirrors ApiOptions). + const routerModels = useRouterModels({ + provider: activeProvider, + enabled: !!activeProvider && ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined, + }) + + // Message-based providers: request the models on mount and keep the + // latest list delivered by the backend. + const [openAiModels, setOpenAiModels] = useState([]) + const [ollamaModels, setOllamaModels] = useState({}) + const [lmStudioModels, setLmStudioModels] = useState({}) + const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + switch (message.type) { + case "openAiModels": + setOpenAiModels(message.openAiModels ?? []) + break + case "ollamaModels": + setOllamaModels(message.ollamaModels ?? {}) + break + case "lmStudioModels": + setLmStudioModels(message.lmStudioModels ?? {}) + break + case "vsCodeLmModels": + setVsCodeLmModels(message.vsCodeLmModels ?? []) + break + } + }, []) + useEvent("message", onMessage) + + // Request models on mount when a message-based provider is active + // (mirrors Ollama.tsx / LMStudio.tsx / OpenAICompatible.tsx behaviors). + useEffect(() => { + if (!activeProvider || !MESSAGE_BASED_PROVIDERS.includes(activeProvider)) { + return + } + + switch (activeProvider) { + case providerIdentifiers.openai: + if (apiConfiguration?.openAiBaseUrl && apiConfiguration?.openAiApiKey) { + vscode.postMessage({ + type: "requestOpenAiModels", + values: { + baseUrl: apiConfiguration.openAiBaseUrl, + apiKey: apiConfiguration.openAiApiKey, + customHeaders: {}, + openAiHeaders: apiConfiguration.openAiHeaders ?? {}, + }, + }) + } + break + case providerIdentifiers.ollama: + vscode.postMessage({ type: "requestOllamaModels" }) + break + case providerIdentifiers.lmstudio: + vscode.postMessage({ type: "requestLmStudioModels" }) + break + case providerIdentifiers.vscodeLm: + vscode.postMessage({ type: "requestVsCodeLmModels" }) + break + } + }, [ + activeProvider, + apiConfiguration?.openAiBaseUrl, + apiConfiguration?.openAiApiKey, + apiConfiguration?.openAiHeaders, + ]) + + // Map provider -> config field key + model list + default id. + const result = useMemo(() => { + if (!activeProvider) { + return { + provider: undefined, + models: null, + modelIdKey: undefined, + defaultModelId: "", + isLoading: false, + } + } + + const defaultModelId = + (activeProvider === providerIdentifiers.zai && apiConfiguration?.zaiApiLine === "china_coding" + ? mainlandZAiDefaultModelId + : (ROUTER_DEFAULT_MODEL_IDS[activeProvider] ?? STATIC_DEFAULT_MODEL_IDS[activeProvider] ?? "")) ?? "" + + let models: Record | null = null + let modelIdKey: ModelIdKey | undefined = undefined + let valueTransform: ((modelId: string) => unknown) | undefined + let displayTransform: ((value: unknown) => string) | undefined + let isLoading = false + + switch (activeProvider) { + case providerIdentifiers.openrouter: + models = routerModels.data?.openrouter ?? null + modelIdKey = "openRouterModelId" + break + case providerIdentifiers.requesty: + models = routerModels.data?.requesty ?? null + modelIdKey = "requestyModelId" + break + case providerIdentifiers.unbound: + models = routerModels.data?.unbound ?? null + modelIdKey = "unboundModelId" + break + case providerIdentifiers.litellm: + // The settings page reads litellm models from the backend + // broadcast cache (stateRouterModels), not from react-query. + models = stateRouterModels?.litellm ?? null + modelIdKey = "litellmModelId" + break + case providerIdentifiers.vercelAiGateway: + models = routerModels.data?.[providerIdentifiers.vercelAiGateway] ?? null + modelIdKey = "vercelAiGatewayModelId" + break + case providerIdentifiers.zooGateway: + models = routerModels.data?.[providerIdentifiers.zooGateway] ?? null + modelIdKey = "zooGatewayModelId" + break + case providerIdentifiers.opencodeGo: + models = routerModels.data?.[providerIdentifiers.opencodeGo] ?? null + modelIdKey = "opencodeGoModelId" + break + case providerIdentifiers.kenari: + models = routerModels.data?.[providerIdentifiers.kenari] ?? null + modelIdKey = "kenariModelId" + break + case providerIdentifiers.nanogpt: + models = routerModels.data?.[providerIdentifiers.nanogpt] ?? null + modelIdKey = "nanoGptModelId" + break + case providerIdentifiers.kimiCode: + // Kimi Code stores its model id in apiModelId (mirrors useSelectedModel). + models = routerModels.data?.[providerIdentifiers.kimiCode] ?? null + modelIdKey = "apiModelId" + break + case providerIdentifiers.poe: + // Same as litellm: poe uses the backend broadcast cache. + models = stateRouterModels?.poe ?? null + modelIdKey = "apiModelId" + break + case providerIdentifiers.openai: + // OpenAI Compatible: the list is fetched from the baseUrl via + // `requestOpenAiModels` and delivered through `openAiModels`. + models = + Object.keys(openAiModels).length > 0 + ? Object.fromEntries(openAiModels.map((item) => [item, openAiModelInfoSaneDefaults])) + : null + modelIdKey = "openAiModelId" + break + case providerIdentifiers.ollama: + models = Object.keys(ollamaModels).length > 0 ? ollamaModels : null + modelIdKey = "ollamaModelId" + break + case providerIdentifiers.lmstudio: + models = Object.keys(lmStudioModels).length > 0 ? lmStudioModels : null + modelIdKey = "lmStudioModelId" + break + case providerIdentifiers.vscodeLm: + models = vsCodeLmModels.reduce( + (acc, model) => { + const modelId = `${model.vendor}/${model.family}` + acc[modelId] = { + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + description: `${model.vendor} - ${model.family}`, + } + return acc + }, + {} as Record, + ) + modelIdKey = "vsCodeLmModelSelector" + valueTransform = (modelId) => { + const [vendor, family] = modelId.split("/") + return { vendor, family } + } + displayTransform = (value) => { + if (!value) return "" + const selector = value as { vendor?: string; family?: string } + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + } + break + default: + // Static models providers (anthropic, bedrock, gemini, etc.). + models = MODELS_BY_PROVIDER[activeProvider] ? getStaticModelsForProvider(activeProvider) : null + modelIdKey = "apiModelId" + } + + isLoading = + (ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined && routerModels.isLoading) || + (activeProvider === providerIdentifiers.openai && + !!apiConfiguration?.openAiBaseUrl && + !!apiConfiguration?.openAiApiKey && + openAiModels.length === 0) + + return { + provider: activeProvider, + models, + modelIdKey, + defaultModelId, + isLoading, + valueTransform, + displayTransform, + } + }, [ + activeProvider, + apiConfiguration, + routerModels, + stateRouterModels, + openAiModels, + ollamaModels, + lmStudioModels, + vsCodeLmModels, + ]) + + return result +} diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 687d9962a3..5b8c042790 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -495,5 +495,9 @@ "title": "Proveïdor ja no compatible", "message": "Aquest proveïdor ja no està disponible. Selecciona un proveïdor compatible per continuar.", "openSettings": "Obrir configuració" - } + }, + "searchModel": "Cerca models...", + "modelListEmpty": "Aquest proveïdor no té models disponibles", + "useCustomModel": "Utilitza el model personalitzat: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index e862fa9564..ea5b4ffd62 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -495,5 +495,9 @@ "title": "Anbieter wird nicht mehr unterstützt", "message": "Dieser Anbieter ist nicht mehr verfügbar. Wähle einen unterstützten Anbieter aus, um fortzufahren.", "openSettings": "Einstellungen öffnen" - } + }, + "searchModel": "Modelle durchsuchen...", + "modelListEmpty": "Für diesen Anbieter sind keine Modelle verfügbar", + "useCustomModel": "Benutzerdefiniertes Modell verwenden: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index cc02656a30..2541fb98d4 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -483,5 +483,9 @@ "title": "Provider no longer supported", "message": "This provider is no longer available. Select a supported provider to continue.", "openSettings": "Open Settings" - } + }, + "searchModel": "Search models...", + "modelListEmpty": "No models available for this provider", + "useCustomModel": "Use custom model: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 937cb0cf56..01aff817c9 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -495,5 +495,9 @@ "title": "Proveedor ya no compatible", "message": "Este proveedor ya no está disponible. Selecciona un proveedor compatible para continuar.", "openSettings": "Abrir configuración" - } + }, + "searchModel": "Buscar modelos...", + "modelListEmpty": "No hay modelos disponibles para este proveedor", + "useCustomModel": "Usar modelo personalizado: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 4656d618a7..df937ecfea 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -495,5 +495,9 @@ "title": "Fournisseur plus pris en charge", "message": "Ce fournisseur n'est plus disponible. Choisis un fournisseur pris en charge pour continuer.", "openSettings": "Ouvrir les paramètres" - } + }, + "searchModel": "Rechercher des modèles...", + "modelListEmpty": "Aucun modèle disponible pour ce fournisseur", + "useCustomModel": "Utiliser un modèle personnalisé : {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index c36bca4e0f..8bafad2daf 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -495,5 +495,9 @@ "title": "प्रदाता अब समर्थित नहीं है", "message": "यह प्रदाता अब उपलब्ध नहीं है। जारी रखने के लिए कोई समर्थित प्रदाता चुनें।", "openSettings": "सेटिंग्स खोलें" - } + }, + "searchModel": "मॉडल खोजें...", + "modelListEmpty": "इस प्रदाता के लिए कोई मॉडल उपलब्ध नहीं है", + "useCustomModel": "कस्टम मॉडल का उपयोग करें: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index d8a642b8ee..6a3fe89f24 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -501,5 +501,9 @@ "title": "Penyedia tidak lagi didukung", "message": "Penyedia ini sudah tidak tersedia. Pilih penyedia yang didukung untuk melanjutkan.", "openSettings": "Buka Pengaturan" - } + }, + "searchModel": "Cari model...", + "modelListEmpty": "Tidak ada model yang tersedia untuk penyedia ini", + "useCustomModel": "Gunakan model kustom: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 6ce80b2316..3f9b809c66 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -495,5 +495,9 @@ "title": "Provider non più supportato", "message": "Questo provider non è più disponibile. Seleziona un provider supportato per continuare.", "openSettings": "Apri impostazioni" - } + }, + "searchModel": "Cerca modelli...", + "modelListEmpty": "Nessun modello disponibile per questo provider", + "useCustomModel": "Usa modello personalizzato: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index f9e535ee89..0211190cd5 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -495,5 +495,9 @@ "title": "プロバイダーのサポート終了", "message": "このプロバイダーは現在利用できません。続行するには、サポートされているプロバイダーを選択してください。", "openSettings": "設定を開く" - } + }, + "searchModel": "モデルを検索...", + "modelListEmpty": "このプロバイダーで利用可能なモデルはありません", + "useCustomModel": "カスタムモデルを使用: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index a633602e26..8c4185593c 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -495,5 +495,9 @@ "title": "공급자 지원 종료", "message": "이 공급자는 더 이상 사용할 수 없습니다. 계속하려면 지원되는 공급자를 선택하세요.", "openSettings": "설정 열기" - } + }, + "searchModel": "모델 검색...", + "modelListEmpty": "이 공급자에 사용 가능한 모델이 없습니다", + "useCustomModel": "사용자 지정 모델 사용: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 67407b6b7b..bd92d05864 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -495,5 +495,9 @@ "title": "Provider wordt niet meer ondersteund", "message": "Deze provider is niet meer beschikbaar. Selecteer een ondersteunde provider om door te gaan.", "openSettings": "Instellingen openen" - } + }, + "searchModel": "Modellen zoeken...", + "modelListEmpty": "Geen modellen beschikbaar voor deze provider", + "useCustomModel": "Aangepast model gebruiken: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index f7f88dc87a..fba8b93446 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -495,5 +495,9 @@ "title": "Dostawca nie jest już obsługiwany", "message": "Ten dostawca nie jest już dostępny. Wybierz obsługiwanego dostawcę, aby kontynuować.", "openSettings": "Otwórz ustawienia" - } + }, + "searchModel": "Szukaj modeli...", + "modelListEmpty": "Brak dostępnych modeli dla tego dostawcy", + "useCustomModel": "Użyj niestandardowego modelu: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1adf5870a8..a08d6bf04f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -495,5 +495,9 @@ "title": "Provedor não é mais suportado", "message": "Este provedor não está mais disponível. Selecione um provedor compatível para continuar.", "openSettings": "Abrir configurações" - } + }, + "searchModel": "Pesquisar modelos...", + "modelListEmpty": "Nenhum modelo disponível para este provedor", + "useCustomModel": "Usar modelo personalizado: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index f42363c5f4..b79468065e 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -496,5 +496,9 @@ "title": "Провайдер больше не поддерживается", "message": "Этот провайдер больше недоступен. Выберите поддерживаемого провайдера, чтобы продолжить.", "openSettings": "Открыть настройки" - } + }, + "searchModel": "Поиск моделей...", + "modelListEmpty": "Нет доступных моделей для этого провайдера", + "useCustomModel": "Использовать пользовательскую модель: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 4d8ba6fead..bc10ad8643 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -496,5 +496,9 @@ "title": "Sağlayıcı artık desteklenmiyor", "message": "Bu sağlayıcı artık kullanılamıyor. Devam etmek için desteklenen bir sağlayıcı seçin.", "openSettings": "Ayarları aç" - } + }, + "searchModel": "Modelleri ara...", + "modelListEmpty": "Bu sağlayıcı için kullanılabilir model yok", + "useCustomModel": "Özel model kullan: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index d48351deac..41ffae05c8 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -496,5 +496,9 @@ "title": "Nhà cung cấp không còn được hỗ trợ", "message": "Nhà cung cấp này không còn khả dụng. Hãy chọn một nhà cung cấp được hỗ trợ để tiếp tục.", "openSettings": "Mở cài đặt" - } + }, + "searchModel": "Tìm kiếm mô hình...", + "modelListEmpty": "Không có mô hình nào khả dụng cho nhà cung cấp này", + "useCustomModel": "Sử dụng mô hình tùy chỉnh: {{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 00b0e3a85b..64420a6a49 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -496,5 +496,9 @@ "title": "供应商不再受支持", "message": "此提供商已不可用。请选择受支持的提供商以继续。", "openSettings": "打开设置" - } + }, + "searchModel": "搜索模型...", + "modelListEmpty": "此提供商没有可用的模型", + "useCustomModel": "使用自定义模型:{{modelId}}", + "selectModel": "Select a model" } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index d04b1fb08b..80a15f1179 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -486,5 +486,9 @@ "title": "供應商不再受支援", "message": "此供應商已不可用。請選擇受支援的供應商以繼續。", "openSettings": "開啟設定" - } + }, + "searchModel": "搜尋模型...", + "modelListEmpty": "此提供者沒有可用的模型", + "useCustomModel": "使用自訂模型:{{modelId}}", + "selectModel": "Select a model" } From 2f6b25d399057a01f3210424368029e32dc64b7a Mon Sep 17 00:00:00 2001 From: seeones Date: Sun, 27 Sep 2026 11:54:24 +0800 Subject: [PATCH 03/35] test(webview): cover model selector provider/transform/clear/early-return paths --- .../chat/__tests__/ChatModelSelector.spec.tsx | 99 +++++++++++-- .../__tests__/useChatModelSelector.spec.tsx | 138 ++++++++++++++++++ 2 files changed, 228 insertions(+), 9 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index a819971bcb..65642a1715 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -23,16 +23,13 @@ vi.mock("@/components/ui/hooks/useRooPortal", () => ({ useRooPortal: () => document.body, })) -// Mock the ExtensionStateContext +// Mock the ExtensionStateContext (configurable per test) +const { mockUseExtensionState } = vi.hoisted(() => ({ + mockUseExtensionState: vi.fn(), +})) + vi.mock("@/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - apiConfiguration: { - apiProvider: providerIdentifiers.anthropic, - apiModelId: "claude-opus-4-20250514", - }, - organizationAllowList: { allowAll: true, providers: {} }, - currentApiConfigName: "default", - }), + useExtensionState: (...args: unknown[]) => mockUseExtensionState(...args), })) // Mock useSelectedModel @@ -98,6 +95,14 @@ describe("ChatModelSelector", () => { beforeEach(() => { vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) mockUseChatModelSelector.mockReturnValue({ provider: providerIdentifiers.anthropic, models: anthropicModels, @@ -176,4 +181,80 @@ describe("ChatModelSelector", () => { }, }) }) + + test("clears the search query via the clear button", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + const searchInput = screen.getByPlaceholderText("chat:searchModel") as HTMLInputElement + fireEvent.change(searchInput, { target: { value: "claude-3" } }) + expect(searchInput.value).toBe("claude-3") + + const clearButton = searchInput.parentElement?.querySelector(".codicon-close") as HTMLElement | null + expect(clearButton).toBeTruthy() + fireEvent.click(clearButton!) + + expect((screen.getByPlaceholderText("chat:searchModel") as HTMLInputElement).value).toBe("") + }) + + test("does not post a message when selecting with no apiConfiguration", () => { + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", + isLoading: false, + }) + // Simulate a missing apiConfiguration so onSelect takes its early-return branch + // (the mock of useExtensionState returns an apiConfiguration; the real early + // return also triggers when modelIdKey is unset, which we cover via a hook mock + // without a modelIdKey below). + mockUseChatModelSelector.mockReturnValueOnce({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: undefined, + defaultModelId: undefined, + isLoading: false, + }) + + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + const option = screen.getByTestId("chat-model-option-claude-opus-4-20250514") + fireEvent.click(option) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + test("renders the display-transformed value for compound configs (e.g. VSCode LM)", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o" }, + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.vscodeLm, + models: { "copilot/gpt-4o": { maxTokens: 1, contextWindow: 1 } }, + modelIdKey: "vsCodeLmModelSelector", + defaultModelId: undefined, + isLoading: false, + displayTransform: (value: unknown) => { + const selector = value as { vendor?: string; family?: string } + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + }, + }) + + render() + + // The trigger shows the display-transformed value instead of the raw model id. + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toHaveTextContent("copilot/gpt-4o") + }) }) diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index 6f19925992..8fd1d4045a 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -155,6 +155,91 @@ describe("useChatModelSelector", () => { }) }) + describe("requesty (dynamic router provider)", () => { + it("reads requesty models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.requesty]: { "openai/gpt-5.1": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.requesty, requestyModelId: "openai/gpt-5.1" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("requestyModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.1"]) + }) + }) + + describe("unbound (dynamic router provider)", () => { + it("reads unbound models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.unbound]: { "openai/gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.unbound, unboundModelId: "openai/gpt-4o" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("unboundModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) + }) + }) + + describe("vercel-ai-gateway (dynamic router provider)", () => { + it("reads vercel-ai-gateway models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { + [providerIdentifiers.vercelAiGateway]: { "openai/gpt-4o-mini": { maxTokens: 1, contextWindow: 1 } }, + }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vercelAiGateway, + vercelAiGatewayModelId: "openai/gpt-4o-mini", + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("vercelAiGatewayModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o-mini"]) + }) + }) + + describe("kimi-code (dynamic router provider)", () => { + it("reads kimi-code models from the react-query routerModels using apiModelId", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.kimiCode]: { "kimi-k2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.kimiCode, apiModelId: "kimi-k2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("apiModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["kimi-k2"]) + }) + }) + describe("openai (OpenAI compatible)", () => { it("requests openAi models on mount when baseUrl and apiKey are set", () => { mockUseExtensionState.mockReturnValue({ @@ -295,6 +380,28 @@ describe("useChatModelSelector", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "requestLmStudioModels" }) }) + it("uses models delivered through the lmStudioModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.lmstudio, lmStudioModelId: "local-model" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ + type: "lmStudioModels", + lmStudioModels: { "local-model": { maxTokens: 1, contextWindow: 1 } }, + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(result.current.modelIdKey).toBe("lmStudioModelId") + expect(Object.keys(result.current.models!)).toEqual(["local-model"]) + }) + it("requests vsCodeLm models on mount and builds model records", async () => { mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm }, @@ -318,6 +425,37 @@ describe("useChatModelSelector", () => { expect(Object.keys(result.current.models!)).toEqual(["copilot/gpt-4o"]) expect(result.current.modelIdKey).toBe("vsCodeLmModelSelector") }) + + it("transforms vsCodeLm model ids to vendor/family and formats display values", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o" }, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ + type: "vsCodeLmModels", + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + + // valueTransform: "copilot/gpt-4o" -> { vendor: "copilot", family: "gpt-4o" } + expect(result.current.valueTransform!("copilot/gpt-4o")).toEqual({ vendor: "copilot", family: "gpt-4o" }) + // displayTransform: { vendor, family } -> "copilot/gpt-4o" + expect(result.current.displayTransform!({ vendor: "copilot", family: "gpt-4o" })).toBe("copilot/gpt-4o") + // displayTransform returns "" for missing values + expect(result.current.displayTransform!(undefined)).toBe("") + expect(result.current.displayTransform!({ vendor: "copilot" })).toBe("") + }) }) describe("retired providers", () => { From f36f9a76ad3bc3687ea28b29fefaebdc4726752e Mon Sep 17 00:00:00 2001 From: seeones Date: Sun, 27 Sep 2026 12:52:14 +0800 Subject: [PATCH 04/35] test(e2e): regenerate electron chat-dark sidebar snapshot from pr/chat-model-selector source --- .../electron-chat-dark-sidebar.png | Bin 30321 -> 30026 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9..3c03cbac035d27aa3820b5de342f0cf07ad05074 100644 GIT binary patch delta 10520 zcmb7qc{r5e-)|ThnwS_R#E_V*lZ5O;_FcAQZ|wV$-8|W|Z`qPPB!nbMNRlmN-?!}h z7P6na-+SJF&UKyZJ?HtuH8Ys&x$pb?{cPW7dyS}lg{VA61-k4w`HzyF+GvU?((}$Y}McZ4S zr+QAImh;o|%^B#rM2j=D<4P;6^l<+D<#;q!JH;^9_|$fA}y z9(`0kuXt>k;Z)PQ@6-}G4$1vFrgVbMo`d`|3kRzPisgU-7TU{Jpmg+R==|c;%gHer z>zBz-cBi0B^-E1Aq3P$+8)Oth=XHA&g2z4+g6BL^);C(8Mn$NKJ2UvT=FB%aY{{fT zS0DRRjlCQ_zd4VRhQr>Isj$gDyNSiZWq5mj& zG&l|ouQOIpaCTybzeb{D@6ks~=RB9oyZ1D_s*_xYllFxa{5672@vU~%b+sXMFnBev zGU`SeFmCc@z&7xWS=6H-Ja3tX6+z8lldMNFz(5yt*+na#u9g& z7DO$L2;w?MD(Lio@S+qIg3sN=BJ6B!dzam7izFXtMzzcaNTE{_9h6;%Yj9|0Gr@ER z!N+Mh=9pQ*^iF%hj#SJVIZIsZFKYo&QBmWvY8A$?rknxWpvG*&@b;U*R$Z@d|7ufH zQ-j)5mEm)f&vM^jBH)2^h>Pi+q3G75BOkUMj>XG?aDLWUWc|UUFFf$WhY#)^9>xuX z8~Ro4J9Dx3QL^_ly7KdHg_^BoTzmdXzjP4KPtHpbtl=?tMH=lY)*YD(f4ApTXIy6T z=n>HsL*A6JbqT`B-<6fO9rNF?KUenx-(P`XGwvc!MQ$`<9jistJFkk3TMK-wuI>}a zXv2#!QN5ujuQR5KS~ht6IH+;@_iuA6D=RlQH!R&BpE^^YiQ(bl$w@Wj_aFbRi>Z+3 z)2G*?<#5Qsp&^oh3*~Z4Yis?>?rAEPW@bceueFLkfBq~$AB~3F+1a5Bn$0W5)*<1T zJPi1lV#n|m#T=GM_s0GLhh}Dw)=w;=Z(`yy`zN_P%_419xOwHsE49L*)ypvAyu3V$ z5Mo?hMMZ@Q`&|i%`pU|?A|eJ`jL~v;g@r$T`0!`achkYiiHeHqn!K|}Mj%>6P0gTm zFeis4*)@&LixNN6%`e39-h6u zy#R++c{w?)qJA=FKO{wnoVXKs?o+qTx`o-adA~m zREtX>`h^PVPFwThZqa~kmBIS%;j?d+rlzLO&Kkx^i0N$9UG5IKyq<-H1*o&L^W#TB z4Qj@g#f1e>Uffsh92}|)N{h6WP|e_UVK_lS!8MQCHataGQ?DDi6Xx^3^2rwDfp8dc zU0oerD6Mta{TPz&gF`>d%Ik*5r-Tf(#e-ub-l4TMd(I?j_&kxz^G7kOt5!$^-5-?O|(v9aAj=77ViEUw^r$Qqi-qXi|UE@vZm~nn)Bh=yo5BxN-5fBg%d5w=BL4&vmHp;bTyuZJHd$z^b$LII< zUr>B>%ITe!g?(wAmX#wVa(RtdZbr&lL*AgqukY=Zl`s8zouoqQ3co=8siua!fG!ZN zU#jc<^e!2M34Z+eG1y1JbRHCiVn6~spgv^r&|2W=?09o?9|^vcKaJO<9)6rm&%!;)(-78Jz| zQ4ekt<(|n&%7sr?BX0p@ASTM|fs4JyMEES_gnfOWDV?K%MUb$SS5~@FjSP>EuPrTI z7@`czD=Z|##m&sjfFr^oK`+bXAQcl}xL2Z{YDL=bxoc}`(vDFiHV7(qk>rMv}?@+O_c2R{`#0$w{P8KrB!!9pG^YW zlL|VlDg}&qu{(=wZ)5im6A|%%Rjtqsj}Jz}6WFeYdY_*jC>P{v75Vu1czAg5r$vp} zd(;?4Mn;-G=Q5I!(0!f@{}7%{WoTqXdbH*Dy6QpAn;9v(YW zvLZWK+uV$8neFK4sLdxng!K#$TUU+<$2{arN>eGoyOrq{4-E}*jpo`rIOrtiw&6wI zWNH)-jM{$$yA9b5y`w$R(MoPV*gQTjQI^QTt3|Aer*zB`Od{?)x6Yk>wf+bWSX6(-^X zKhO+)%UDE*Wo3X=cEQY@IfH|Nr{~ z=zkIpjT9`xE0WD1@<6G+KmGkE;e<)^if2!X8x=+tH7SPySppaeG}e0K5kz*f<-(wF_{{N#$gU*O;~o&{`{mtjrsZc zpeWzHtKs;Rk;Lju*Il+UX8zN+dT z9fFsa7qq&dkdQK;G)gwT6Tpm$rnVT9XYB;gje6)-EkM%B%E}@c$ww53CLl7RH zH?>3-Uxpf45H72`u*Vw66d^&ujq#eizU3HZNy<Jn)_`b=iirul_%1K6$-DxT{EV=KfB>F9jh%x-F=1_ELk{^M zuSYmT4Tl7ul&JS!%~T=c52qL(?h)4h{|g zf$JWktyb#lv2uB7sj2z~1}m$p#SkIFT{sR02QBrwLRUsc27_W%?6nr)<>3hkDrk() zCk>;rs(chPD;xtDg-sFg*;JjcP-0uKG)u$O)a0abT>HS@ph_$$Yy^L3YeSkLy9KO-!tQ--OlmzRXaP(wPBoeP_nN?(m5&_E2Zo5By8qivy z4qRzN`*N;8%3M{~kP80RtTB_Z$k|~zVPs_MWuT(cnWd8NH_B2|QoXEmh*G9kOiTB`^ZYSwGi zO#7zCn(aC$jqE<2*imlCj=DzpN-1s=--`6or;%_xDUYQ)vV|S=BHTbd2 zI3QgU2=viEGBR!?TjbY*cm1)4S<>6MDY0&Kb@gj1CbF8am+9cNMLjpxj2%J*1O(uL z8Wl9H-JGFjbfFgfz#Bc>DW-+tax|XVp^Hg%GEAW1(_ejkiE4!{EiJgDOko$|;A?v5 z?%m$wBnsfsYB%d6XFoNX3VW=jHqBt@^Z;(r#jqzT7Og&SHlK0!!sGESF55q%834HJ zl^NH$%*rW}&k0ldv6{wEE+FVat3G^CSQnB)$>wX3ztl-!!~Mkbx4FD(HYZY*pwmr) z5I@Nf!&0AsD)CqYvKWZr=`_<`V$|JRGv3BU+JUP;r2~cJyzVRvwAEK02rlG;4cmRmkpvDW6DZtEe zCe;>|*-HC}niBLS&Y|qLE{rKFeqLUmDk?OQ!9d+$Gl)tP`j*F`Nr|)8Uo`xPAkh4? zrs3w6mOmV;V`E}m8B^Nun1~&px?VVER%vPJU8^o&?_*osj`!DRX3T0Q)HM5hdwT(H z6I4*LR+g3k4}l5NFa=%=B?}zjzfx2y1jpqHJ?1ZeHdgIKMM-H{`6wb+YIfbD7C1g$ z!%uM?^SM3N6}Asp=|LNb2nsIj9s4!oT!mxt37V2af%acG!w;!k5D{58X_@EZSYwR-xL_5mb^WJ6Jq z5)+|h3kwQ>i|$<(z#)NBSP*tRFBUe1P4@Rc(Clw-k7&b>@+<(%URrw7Y>v}(2kJ$D zj49U2)b#7TYrrD}=0&3^FuMmbDbI;jP4B(v7a#_dCmaJ#w1WzkSHBb4FC4Q-C2>1~ zleqy8!xnJT@g^gnpYk~xsHpD>39yJP{_7VoNlM8UIF(=a*WP4MwDMU3s`&HA9mg3I z6a+L&v+Zksst1}MRG3q#==SF2%DpA^7=8VzDV@j3 z-|gjP8}GZ`-59Z0*QfYG4(=sJ^gYeaIHH~KyrWDFrpUB}NTN8-fZv{3 zB&E;}OPmNv2$HzyZkPs~Dy{DZR`RVUFPiMR*v0W{yoPW1cbcdq4Zm;>jE&h<8E{RG z4G+WDN_X$jA^t({Cr4{*uRlHV-I?OWeap{6Jnz(o=*=~fF>}|n8gpf2WB~c2Bge$Z zC&eTLVPhv50CZ39%QO2!+V4d^r996a9vad!cJXgu2}0{F?IF{oy~ zY^5?uMAHsW*M;y0(gsSzYM!9QRr9sLVKl3Mb@_t#x_9B`tbx<=`g*>Nh@>;nt=`@> zFUOLU@<;5?nwZb9;^I6LA+uifM#iO#;yx(u#mUc4m9K_qwDBhs$@XYq+@ZPF_)`vl z>o`@>w|&b}G^^WQ&+c1POc@YPB8;JnK{ZLP1X zs}`ymNJF5v;NM0^A$sTFN`3CHAyl$G0|Ntrq>9mUG6{Ijq|wd^HO~5`nXlNAvN9(N zi;!H5vmW6UvD1%;81KWb2+;OD)+k}FccrB@hP;R-(3u*jq<1)1&ZM{P{JLq(4O8dS zK~z8_emg^=yZ?~O_5gn{Ugzrx@+&Vdx?t3C>HNbhJkNACZ;&(>j1++3%S*na_=2gT+z~_jIBunde5Mb}! zY7C9bgfi6r=X{9t*vGocAgHlC9jeWEz@IigI%@vNlE=I2fxI zL#Fh)UlR2QUb5YvO|jPeY0o4+0xPD^p{V&n0QPHj&$Mf>zyHO5M>OsB?B&qEHM##R zo_ti1y3(QlkkD^i6?LpZ=#5tuHMr36zL{Xx+dx})_HCxT+1&f@OxX2MO$^F(U;p7n zkj|=SN!*|e1I!3{T;fZVnYIN9BQwaARM-<0*q-s;$+8xphr_zEB(vJ^??+0y7xOTj z)ZxMzUGec(KyaFXr&~BZn38<1+c2E&BGmF_C4)Ays6J|7Q643mp{AEO)H)+4$QRM8 zUN%>)3(k_QOdw=xT#TaZe@^Mx#dpl%;Kj*S#C>jOSFVd|7>-jFHfr7! zyC+Y8Vt84g%H9_s(wy|uj}Uq=U81kDXHk;MTu!2pb~$WAhVmoF7s)&6osdWdWmCDm zAOjq>QXC+RwrW0o5nr(Uvg^v6_sEqm zOM4+jb)?Z@2`_{5FS3*nsvp7UBnsKhig57jp#i?f(rDUvv2Sl*f4o0)>fv(}3{R-T zzvDU~@_Nog|um@rdP%ry3@On$ieV+Dv|) zEMJdtDAkT~5}>~V3vZeA6>VX7CHe4_i3eb*Y|n&8jRe}6u@FPivTduTG-(wAF(Ru5 zcV|fWgx_V09SzQXO#N{}cPnBVfP3v#8@!zV2~x6laLFu)rIP2XTX$L}{=IC-2k0XIyj7-V|k0F$<^;{i5j| zz4&oExckV8EG_@-Ruweb6%1)_ue-nfbMgK3>vz91qz~k{uimAP=1fu=U1_-B4DWY5 zpCkcRUMZjRX4sByE0JbJW^ou>8}KlJnGhI;n3?71GG~LRHCit3U)0Z|&!Emm4Z;*U z#Kv8x=KfzaD-4fG_FF2I64%m(Nmz3PZjGzt{?W6*(j~OLj&g*Ub1}A6qZ2mw0V<$y~U4{)L=mV>`UObbh3u^09qT@H~3@dhyd ziESXs=&`mJe2k@gv28RoNTVg({);)~@{pE$WZ<~YAx`tf7r}Sj9m)5<2BV*U<^mQ3 zlV=xSCrt@G-`O(+!-qW|@g-&I@CV#iX<&``qr`W)(^l92`1pg2sJ~yk@eP<7klA}6 z)Ix#gec__78H7x*e)omFfUl0gQtQW7!&3e#-S$LArcBQ+@n+(!>DxEL*WMZcm2BKB z!<)jj8$QKgWG&l{m)WPdk8U4wlkliLJvrPpf{cus!@5JHbE2a!H%yrn_r)5Qc?vTE zox?-JK^<;b<7$D&gS@;wUoJsaeImucH~ugs`ID2PefHnA=B2stHwV5MCR;tKuD6H+ zE~sUQ?DaoXuiO5?p!d$ujE+4+jf0xp84Z{`*T%=)-5rSKb&nv;CC5VCV$iIYi3w!l zP5y$8nOPD#I~gd}v#=S(+kHD{QHh z72PEyEbZYV2bE_Fo;q&+PrpyU*O1J5e#bEUl08xy+W=N~Y)(@cLydF$yIdGDy5fWDObafDnCeKX3O!Ml$_(HpEXXNro zu(R%jM6n~;(E7+PlA`?bH-=*t+@r4>6HA<*-rsv3bk=D|jf*2&|I@JQB!!&QySrU| zO>!q(`^wUg929-NJ8^@z+Qk2bR?*4HqGZ{Ct)1=GK0;PAR`Tp`{7rcv8BBcF?`1A_ zXNX=yTx9m1yAm^N?7uglz$TqAHybPISUEx*@U;v&U5#zhHNZri{cVvXETmbrfA^k? zmyeR0b%Kt^fV1*Vt%_AB>;q{<#;aHE!i>Br#Zq^m%Brfp-K_I}2Gqx)TrTgngv4{U zw!%!Gl?=~G9CEIWBkN@Hpk5EjlHLg#_4HEC2|cO2&uW9?Sy#g3VJX2>ZV_{Ru}^KC zVdFc)#@v$$5zt(S)3Ya?Q-r%)>p~e<}OFOBrGYvfUz8yG;Y20= z=a({H-JD15`AXRt1ul-ye_46k>m*F_Bxh>#{M)s94GHDxXI()H`6Bqmnv`oi6cR>{iIf3-3E3uPzCTe zQTtYEoekWtdHD){jN06ad4E5?JN$h4gU#^UqmA?cg`=jGOKiEpGo~IrF<}# z1Ce8r(){o+UH2{+LCI>mTU)>JOa6MO#P9n1dw5`6*2x@)$zi*m6!f=OnQvW?mFL`l zDu|UXTCN<~&@D_2-3q}bDFLT$mF2%*vzU1)M7lX$myMeIQLu(3moXQEKi21fdzk!Pji+R&< zcr=(2hTx(1V5II`K=EtEW6PH8x?a6SbqBC;7LImm`n zgnTov03jw1Z}Q+tjp4if{e3BPd6qT+#9MG_H@CYHx#{WYAYQF@oEYsU@szP-nZw#= z+SWrQF0RAxe2op=ZO%57mh|qD!*FMvch;Zqu79^YGVxqJ8zPHO;mDm5u_F9>NAOJY zc8d0P5WJ$eSicuH&rsIk{MT179Xy8tAOWD?*m)l(HAP5?ZgKbG>c$30ozYvs`WcrE znPTT$Yn+7{DZ`#rq;@X?k_F?$-Cbz+0R7}p?DS8c6r~ARW5XHfEL&SJ>pSfIyA7g| z9)Z(RCx5OaC6Mi&)PNL@hlhtViIYBB*U9|ik-+kr85Si8W-$56DF7+1z-*kH8oahy zo3Oyo!8y>CCnVd8Fk*5M?HdMgB7(>h;||B*4|mchQ0 zaz6R7bF0%}$L_q_DmT0Y%?fw5xW704reaLGGi={(Yr8V*bS-`A#1f4hK3DlnZ|psv zROY{KCy}LI*cV_-iHid^bTQNSU4H)je2R%2a!y|IsHd`9V)ft{B;17UNR#m(L)zUx zed+*{rY@e&KqHk_4cpO9+AqF#`z_oB)&*1nilTTxL{gG(&+Fh~=3<@kCIC0Pu%;eh zXtGmlT<6=t0O4B0-{zI*j);)0xwgaNqjq*eJSkKrNXH|BC1oo14yd0AAm-!gdUfxF{snN0u0#vht6G@MN_ITjH2 z-okqjCsb7pm|_)@t;_264REjYx%a`*$Q{}6r@c_h=g#9ytIFGa8+*1m*l@CW<6N*( zS;$Lrx5MP@eYQ);T6A-BbA5e1^l#UJ`6m^1{4ETaB-876I1PuI_7lua&0ba%sYHae7%yzbJG=HLJVVA^l-v?Wwu~db@G*j3OsJoI{!;M2>Cux z;cmK1`z_^f#}7K~EtE%UiS(26F-kvbm}NDauhLSBbIqYXIwCtXHMv-;U1XZEM4J=9GItq?=jF^j#+D)twUC|g&^-PH zQ>qW8bvTDfH93zAnq9FnZBAMW*?XO~vd<<+^|plvM$+>mzeo= zr5@l%5`|>uS%yEo9N{OOYLVV`$*MN@3u=6pi)q3hk2qW&uF`H8qwjcs@Lm~KTMC8e z>Z|fqcLP;wTe>I{+J=Ao2z~{oX&Y@@rx?DdFqcXBRT$O{r=z)4I6J`G1;$7~>1$y2 zKW*kjz1&GvsQGnc+ehq)1 z_VEmgfea=YvA_^Z#@t!Gq*HC`mA>_3k7=`_8;qIdZxl#MktHpQf3G!`D zd9hpQy@U}9a)+)Oo(kK}*jMR7ZMZN8><{rNG2AuW21rPo;HDT7CH_2nW|x%mdh zhOn!dy@p*M`i!L^Zs=TFw3I@Ixk9C!fsqWM*}l>t>RWm8Lf(Bt>NZR+SY@(dAd-`c zH}XfymW7-nHn}4&=6f8RH&Rk9*GqQQZ!J;IQDWyyCvh6ZJ;rSGlZBj2WL zsnCr+Oxcdw!3#d(#!|s)Zj?q%i`gHB`b6dEmSdA0 zLfbuZTRYeD8n%3-1nNDzF(>s$R&tn{$w%<@4n7gX|Mg?F=euG_#w?6HV~i}CjJDsM zEKF>N)-z+3KMGppPtAK~AD8p+pyCdLMu7%V2#!=1F&+#%#~-y=tHF|sVWEqb$r)7J zRY^t4qsdCNULgP2(!0&5|!aoH+vmlhfBz4aVAaN!rD5RJRgCYK`+{j>k7BZM~d%5}p6S?CM zu^n@FdH55=&en63l)oIF>_g>^rE)j~TU}T4p3QIzuSo69oWrtano+eAe_$K@K^X>=1GEuah zL!D9Y-a+x@@yHHK+=y%}3I)#|UhlDZibZvIcel(LwcUf=?UMBh{u8GxB98o2U0q#Q zce>E_wz;s1&7-l6L2k&RvbVQ)ko)C&g;ngqN@85AcjB6(I^Yf3lCu?eJt!gHpAY25}KSK6o;Xf=Y3r1h3=7C?;3pB=h554`& z%pORocA5{(I|k(49(-NZoLpL3+P8Vw*Vo6($2UGQ;@M!BKTMc7uBoXR85v0yZvN`u z3$M6IUr+C^w{ZUfm;zlifzAVNKE9&S4Rfw%PoLt_p_977@A2{v`ww2cc!A3AG>0m5 z*!ZJzFkfqH;awljDLU+NwghZ@m9seobCa{Iy?19Cj020tH(znx#iA@KCoC$Tr7K0Z zKjq{^CCJ}~-KC{fV2aZ&GbDlG;Ns%y9;>n>2n!3lxVU8c?suM@c^erS+2g16{yezo z=#T{wa&UOu%Gui5NQxHzBBn^_C^LANfKEq7z&OgVvH^LPakjZuA2|h zzLR7GS$O~ced^Fx)?vDdc*h=f2GTjVeckJ)UswI!cAwln3J(wW6pBK@$!%%mC-9DR(U0OfxkPLTs&ZIaS;5QO z*XZXDM@IR3YnAg)zH2k3kTRi`Rvnp}L&;LW7`jJL=}P(~V^C@;O=_NQqLeB{`yLwu zg+4l6=@A*>-qT;4ngtsFnF#h3Uv~u@tDvmx;o*@f{*IZzKo0v$mj8~ zG0JdB5Ta&--&WkeZL2j{05X6q3pMd?nzgUlbQK6Sd2M48$i%*@j;g`kV4ZORsmvs-K9F zkB?7YVs?4??P4cIrZ_ONr;v98ou_OJ+N)pqI3OhZ4FE`M8d+l(mjlX(a*#(OBQOyV z*6M0rm^et#!oot&46p}|PkmwHvgp|Q-9~+;xF-mZax~HHa7fYQCR8f8rAmkcS6ru=I{baj>(Gj*QSga|X+M?kkd- z_mGURm9s6$1afw+sHr(V*;^F*AT2H3Tv1YAKWSe>j77n}N8aP(j{+bi6HTXxCWDCw zx0)EIF70_n-513FJ-zH4gT9Nvx8v1^pe9L11~@ivomIPZ$uGiy!@mUpR3ZcTAD5HXclN(~m`02Gh23(zdtR5D*Xm*u)LIaZ*!M z#7Y+4)8Fs#m!iW?Qd2Ww!q!4piz)75T<5px28k>kL&KR7%2#%FCCQIy5TWTbgrFcm z6Se?Z8y)8*#O(_yoJ*%qUDwud(@(_vJZ`<4heHsU?G>S&4 zLvz)bqubSo{!%-Kshq~Ve>I_y3t>=n(YrW=u*+9o=Mo+12-jCwF%|@c1pL!?F^1s( z?;oN6r)Tu^5i!6I@tLW<2zxxNRV!fQDw5A-`D3T8J$`m30MEe0bslX`T2|?SARKQ^ z}PMXXtZIP7vyi757)`1$UI3TK+JonHLXpwJHmt!osYqmfd4Z zrq|r5V0Y9vHi}Rq68RKt@d}EHY+k?q%{?${C(lO7%5+akbve9CTqxQ^XdGyaqLV zrq<~J>!4|QPfrgU8ygg__DlahqE$?1RPe(-+~mcJ?tuZM)fgCietzDO|C`6IVNeUF zsOaMuS^Jr9b>QoJ0^=$@u;5-9`Iy(Pt`xza_)-*5wmfe>r4vRi0zx2=eyiQ7Y#hLz zDn%P-k42?*)$bms2M(r8rlC4)ygWQ@PPAAQ4-bz;#8Ac;U`Z_IH^<9(iZnXfkgKo0JP#r zB8RDJYp))9TUuJCrKN$D`av&&gjG%$sH;DZHAj=BWMo+A^48oGkDan4r?D0*n3?oCszDSh@sGhl%CI|%w#B(NXV`BqUY6O1$*RRx}b*}5NZKe$H@2dx| zdM%QYl5hh9`d4wwR4py@rE~7yy{oIIC*b+#qX9?1x4gDCRn#J%{S-9~O<`GCSy2(L zwh6WS<=G(srk|X>X~~^|xCBTTxXVPhx~Pb_BdymW?Zbx;$x!l_30st7p;G?l+S;2o z)5XO_urS6EwG|a)(e3$pd1?h3fDGvCPcAJj4Gg?*GX-f$4hu9%j*7ZPA$7bvyL)_I zSy;ymqW6x2W5KzOEIfF8)2+@xKG)gU*cgy9Pfwi^t=-8QQUz;JuLrGM`Ds(A3P(2p z?xdtZDFp=ue&Js7yeU#}??2+V10_+@2S(5RIuyDEp6VxKR{7r!~~qf7@otk@TWy)R{I(feGw1Yds{aep=+oliL763*uYnZmJ(K+h=4#nWSN@^NyaTrZ|XhEDDs5e6<2lumHo5exUQSzo!Qs*V)!4H6YSy z?(F5ITcQ=DU(QB+hLhC&Fh;i7vSyNmtGKw>px&K5MZuIB;7i`<>jPdC7Ugrab(53` zKL|t|bx$r96R|Hu4~O-Ac&5Je`t`U<=sHF&)>s-M$ZiJdv{;LRV^KgPMP!3mxVThK zZqKZN`~n)Hki#mK5^C!aIzQf(QH{^Dj>x|77yVZqsJ&w%{lN@ig(!safW67}HvdA$4e{ZyO=D<4y)H!7T+PC9HN~RTZew&a{tW zE^;sCZh4VEo%hN>%k3F87=l<*hu$t@lvC3XwP+-&3v7HAb?@H2ZoNh#>drHKq(Jyj$K?8!Ez4mHu?N&)QDYgR8~6;$IB1Z2)rRPdCw{yxwgK#2iCAs)9g z+eH6ql^50Sw6M#n^?z{8%9YXjG%f`hv+`tqSHOvyX2B&YDCo03lnXNZV71@qt1ELE ztb&c$@b(vJ0vmipD`3BESNFggD=^_svxp;6mTZ(}x?|>CP#_1rLeV~x1rC!fYTB6d zpPHHn@QUhc{2f9{cCjaM0M^X8?w)-6U#KPLbc7@$5M5xm&M}+vw7hVroN(6u_R*5r zVtUQJesg1^IW%j+*3ig^DxaY)z&p?{r?iwKL4JA9(^cU9Ndv$R1*S<^KTWa)s0?>3 zETo$>m8RXPy!ZTnGFF1_y92Bfw$NL>9;}fWvE>o0U3jXt}=LUOb$@o)4G^z-vhn2ua4###{!KAwA_G~va<5=@iD!qzfihTm_(Mr=o?uK zg@mfBqvOD`Gf<01M@Mv`{vTFP0g!NUfeM$Fn#y5N+k9|wa^h)exeC-mvaVv(;+A{; z>~6mvBTyt1QV(J}@=CVd>w%hRIG!Q4n668M?xwJDaA14j*b`ro==PxOHE@B9WQ=() ziv|-h)^$Ix=QqYTeECu_cIo?fY>V+P%<9OdmC6C8 zFUgW%(fb3Ay%CX#gLk9DoGa8sOliQ*vLL;pqu2rvtzW-%#?-yNy(uXvokwNy?B8Tt z1wP><=iKb>d6)fJl9Ab9h5gHVKd5rzED0d@LalBOP`!}`;uJUz4leuae{bBNB#@I7 z*RZuM{uXl4(;;|c$rR$G?lQyY5f#m3?LoBKp2htU%B;JX{bBm4Ttel0f-5a4u9z9))Zw?!ltsU*fzyJ-va6;6lVb z4E(5;VxbAh!j#_Bbi5_HhlUpQP_-xirt_rgg{%(U+mvr<(9QRZfeRjCxF!1x?}>0j zkT?aL^(t_Wl;QiFqJ^%^C_z~-L~S&^sG<3a&u#;shjw93PEKQ^|F8|vzX1(yOakdU zrS&^FnLIST;pC5^=!6TkiULDCIPT>-biMSLPVk6p&;Z;;P(UD}XKHxZW$RaYU!T$* z5K>IWbPW&>&{WOKkCZ+jOKyq0fQhedZLt;CHV$Kgkl7f$i+<(=c^4>vvEhmn(ftoU zu4Im?6DuMAbh;=cLKl7f_wUlo&Gx-->uopQy?fE{3Gm$P&pkhYwQ+iS3MyEqxpdCn z)jpW>fE4^@)McO5ca9QUVHg`;-8$?RWz>U zx599f)mP3+UaicIY%1pugVNQxyf2GhaOD5t$37PYNHE}|ofW{?dy=A%r6TM4RlR5w zCjJrQJ=Yw#y!>iVCoKCjs7F12zdwW>B{p{{{LO3QEUu~=-8|fxuJ^?+S=OZ(Ak-oeCeznQ%J*=Y^;#$+Q87z5D=aL4s^2GMWfAo?-u!u zkC@{+n-S`+U-fl#0KQ;R3W|!ab^?Im(eqdF1-xhYo8d0tW=i?EIl`ePz*+)DZ{3xN zEW9<06tF>~25&bDi@f@JeU^kmjlzr!y0F$Kyu8w}=phkjga4jnAi>l3KNAxZ0XHrip9E}rXo!o4r>e2>mcI;4JfhfaZ}~Qi zAx3sdplhlexPMNx9{jZ2iuDE@1sd|mAJYCd0g4S*Xfnc36D;bJS^>~4gA!^;*c=1{ zOf&Y7HA2ZFMi%C|Il!H6%^a(g|8$r*3HfcgK;yNY9eaX22x`PSg_NR#0)Q!TB!}TQ zxIu8U3n&0uMI`t^K&a3~mz9>{sP9L1xYdxG=<-s&y{SL;ZH(Qk;Vv=%irrE%oNdrL z)zHvT3qt|CKW@dx92^li29Wvv7c9zbl*3lsczP>Q*+Rpr#w`dD+@LwO8sWJbCc55= zf^mAqVSsQ=P4ul_9Qn5#`J0P*DBp(DJ(=VdwU&v5Xt&-F7odr72pPOk;v3t-L# z8m81M$L9cSmGaG~^FSU3u%F~zMTB&2_Ubf0DGR7H&D*53B8NUkw{fziD^cmT$9Aem)LC_5*UmCKT1j!Y$!nVymuRp_eHF_Z8vlYgl2z0Z8&essth=G z*Vgzwc4*fEfZ@?n(`in2vLcw?hhwOkcE(D{RK~X`9pxn@z`CO53+751wK$+8Yo1>{ zLiFzwphHVe>RWU|Z%n8ahsDyghj!?7LYFg^5>grqEYrs~d8iR$^dKqHisI06Y9DZ{ zs`S2C0CA?o6bGaNfFitGTWBYR31B0(3u`m5Zm(;O)3Is=YabWqF3UW?7$-6UBwOniXb5BOy1Ylkikcln%q zSZ8#fBrR;}_d+8amDUH^Il^>>@Z$9b@zn0EXt)FhJsQ6yNCZ&oWWFnKkuumyc*{tuV z)suT7V6R8#hy%$6;)d;rxx1skJ$*tG381g_iv|lIDlA2TzZfjrwnt*JKgTY>r}8L7 z$s~YT;U3(7=1B~PRlNG&+a7u5Pbt9PnI96qAe5*jf21jas*jBg&V@0sUHzjAU%Hi# zHenWrTc?Ivo=c$87`=n<$XP=+-v!ydAHXW`vIp0cN(cjq$y{-R#E6($rEx z71%gFH!G(WA|%J@lt2dSuz|dOao9f-L50U({x%yEE~&O;9fItz$EyXxKFKXlbA_s{273my{85o#<6XgT{w z{;heAwC$VhJ1PAy!P9qo%}5hY`iA1wM=^)~fz z{m<|3d8dco!dO%cNbp6^AX%Qa%$5C=J6qKsN5&@o=5*(Yh;m9M$sMky)D9jnr!Yi9 z(AXnPzLT}Q1tQgZ36sCjTA*`uH#lYO@C-0T1pc$A$@Q0t;z<4|KzfrQHSHa_EI~zn+Of?;=Q;xf zk>kJj8V`@L(qU10`uZ*J9v~q&@L}RNa5XeFK`#lAefeA*pT$8dZpyb_4KsF$>`7$O z5_X#D?fWY778oCf>Ht*C-Rxmk?(50o;dP*uq^3kbnipu?V|QTD`7(+9UZO1o$p74c zYq(^#S^>aXY6b^&N7~(;9sJeeGv7wwl_zy4C@>LXL379gs+`z7Omrer|1B%KfKi-g zAt-Gt&|@yodYy#sl%J^OLa3ug__Ye?|hWVxb zjS{WNiHTXEOa3`yjE4L?Fgl4bLahHajim54(`W8mIsmNg|2)g=uc@e5aIb&i zb_Uu(T1AH!rw4fZu%H%~)xN{yV}N(g1uHJBx^!W3 zki${<)gU@R?8d(+da>6&>wmrt?i}QX0FTHX#K1cqC3Q1L&`?va?d)V^WZ+j0L}rUy zViy6IwlpE6sPjJDC^u{G%M>+AQVMDIb|l$^ayZr3$^6y zf-*HvdxO44HkPO^M7!;3AOVv&t?T#W*ww~Fh@7qET>l}xR`Eq1bq&qTKr_?R4OKRT z*jmLPnE%y5rls!Dd6ic3Y$O;US@ zlc+JBtHoW~VXdbe(;P%lNOpeyDtKc5WnobfFdVKnw41(4Y1xBrgsy5XSKaweebb+i z#|DkQUY?$w#C%QHR{#xu)w!%zYyTudBJhEjsO%RJ6cz@x?s*nc=gaz`cZrt1wl?qv z9+HK(FV+Eb`3YZfVPORL-2Dx71+K3AVdAAmO^u-c@ZnSwh2#WQN?d_wLcespBK#3W zBzc7NpSmZ@g4CG@#1*+DE7_y+LO=hkt}Y(y7K}eKgJY*&Wi|fJ6-O>9FUA~OUN4X{ zCw+uOKSEcFH_~QGMdX>{g!elhfZeq92afez%L)T7Wc#AmcGWl=WjC>U#`78f zb7gI#MY(V;op3LFG#OZvt%IUBEgz4T@D*%%AcOx=!)_o*Z=X_@) zEl+Ze#O}EEcPd~1s$F4CevX53yVADi1Gt3gTD0=0@3nCM@$7-G6O^g<=z2u_cKiEG zzpSG!XVY`CH>eKK@O{R^bNJTiBD;KVxNpJX`p5x@M`h?ty=L-qEhif2+L!h2cIM_w zfZW5ezK2Cdc+Y?YZQM^N3wS~2y_N+8>Nb^yy8l7i>>ILQgM%??^FgE{C$02XUt&rx z0tx7nQ&PO*@o$F|SRL#^l%yx8iB(Q3O%?2H9O@Kvc2Q#f{y4>D{ zuFoC7h_9;z4z!G}_LF9P4|=H#JsDCIGWeZv#NR)ViQ?wwzPMNw9k=4nS4!_n8~XkG zj1M~Co4XY)v=4`G>Z_|CoGeBs<@$MMG`X%1LEVp=E~Z@iFAqeo4~CURFaHiJU(XSU zdhXP%8v36u7ipIzj@*Z z1jRL}A2$v;GKzQXTh!0yT^y&JgjMdfNXv)jDCo@%eTM{X*NT#^=Y*9x_pA>t|Cqad zfaHvH*rMf3fWca2Gd{Hbx9u&Jco)d0b|=twl2QeJNYbiTs0nrGP3e1}x5}1+iNLx2 zx+OS&tGf*}_$+fTjzDP6#+z>aY@NMW;q$Wm6k=2{Zs-%4w+{AhzeVNp25CzaZT|sk zMc2V$_i%H3ajzEaM6ZJt;0Z&($^@6VqLJr(2yk8r=sd+hCr*{Bea~aY>rssCH!ujW z%)Hboh(ZAN_j6GrK0bWKmC|C28Ql}2YA___U8qTsL6;$aE;=>MUXQ3N`)%sa?w|VB zg=tCV4C<@GH&X^)W~Gh)*~r{FRg*33OOturUV^`GY4rJW8A{rqHsl}67DFy&Q6(i} zx2PsZ=`Y8HeH77H)bW<8fx*XKy$UT|x_-Yw3mo6~^0~lYT|L0Vxmh-T-feWn#d>|v zFG|W}5f>MCeO7i&L}ui@o@+B+nvg5R2vq0ehQF~VyKf&i!qJ>ILTurnas3xCNzft& z_84lY7UH*9VKL*1Y%?vVj#>l_dT^oMoq9B#PXjMGe|R0R{i~bsdSb)jNVU+gFz9mA zD9Gn(fvO2~CB%^CCMNTbL{B@9JiY?j4`^YIjp3HNkC|DHLL!8~#6AzZwI^is@}(VD z3V#%Cdbg5_1-U3}7^gH3&x$iiC4}(Qrm%nuDDy7j=ya;R9xb|Z| z8B$qGsgLx~rJ#R2l*u*-WxT;BU$@+O;1L1^62O&;L&YVMzE)y?2bSsTW0z}ytKD!YHDy9 zK!;o)-QLgdC19_#1ct*C6B9Wy(HSCsG~&p#-hW{T^iTtZXn@8U{I>V=p0NZmaKb^8 z1hh{|N=i%vW{TtQx2+ziQXKX??GG3fblC>k1AM<_XD7&>Nd>U7P1xkk5vkm&y=o8FI~{zK9Gxeo-6BGcN=#zSRm5Qb2W~4xn}l6-99XSU}F;#g0eQ&L5mgGNgR?;Cr^7 zugEmaTK3KP&s@u{->wf9g&-w;O9tNpXGT`RF0~6a5uzi7KkKU40wn`HLgyv)^77)K ze^qfp^*^oVvWBD7(bpiJy=pxl1U>AXo%dOdPUZKW+m|Bg_#Nl|t`D0^&WDE+B3iwD ze4d7I?xNj^#qD zm6lIQ)j%)h7{KdhSrbRzuk9l-)EC8Vt5dVF%?T?@+rXAMNN4@PpCi&y&~_++@5ZJ5 z;X^!`fLD1-TA~T^;c9wf{uLv~D11AMtEu?=A*)_k6wzwuhCR{Zp*QedTm{mt!jD!( zub;6h^V9aliv_iy;ySCFL{U{86~Bh$heZ z=Wt2JW#M1}YD9)8)L2;wybvw~dAMZ2m3oO5?y7N$!qI#fed4h}ESb=zZel`CG3|18 z>KIp|F)=q=gJS8-8$3P(@}T>i1sZw^HG0wkk+L_d+;abwZPuH9ym0KCd%Y?}LZ><4 zcy)DkSy|#tapi3H!X;N$R-mqP{)lY(U)eXTHcD(q8$TBCDUCXt`==$}m-sG8@i(=_ z2k$^p%Aim20Q8e7q^wh~Y?buYL5~NPub*#RgO^)9!_jKY8P66B$98CVhm4_Ghhh6E z(&d)7g*we#e`UT|$}C$22Kj#u>rJn_$bd6uLUfP)YZfb+f7$W1Q0h7Gl}iC-javS0 n%-IYM9R9Zts{#if53X;vihK$%VGaqq0WLWyCCO3=W9 Date: Mon, 28 Sep 2026 02:11:57 +0000 Subject: [PATCH 05/35] refactor(code-index): separate service factories and embedder validation (#1818) * refactor(code-index): separate service factories and embedder validation * fix(code-index): localize validation errors and identify missing settings --- .../__tests__/service-factory.spec.ts | 251 +++++++++++++---- .../embedder-validation-manager.spec.ts | 59 ++++ .../code-index/embedders/embedder-factory.ts | 51 ++++ .../embedders/embedder-validation-manager.ts | 23 ++ .../factories/bedrock-embedder-factory.ts | 14 + .../factories/gemini-embedder-factory.ts | 13 + .../factories/mistral-embedder-factory.ts | 13 + .../factories/ollama-embedder-factory.ts | 17 ++ .../openai-compatible-embedder-factory.ts | 14 + .../factories/openai-embedder-factory.ts | 17 ++ .../factories/openrouter-embedder-factory.ts | 15 + .../embedders/factories/require-setting.ts | 8 + .../vercel-ai-gateway-embedder-factory.ts | 13 + .../interfaces/directory-scanner-factory.ts | 15 + .../code-index/interfaces/embedder-factory.ts | 7 + .../interfaces/file-watcher-factory.ts | 19 ++ .../interfaces/vector-store-factory.ts | 6 + .../__tests__/processor-factories.spec.ts | 186 +++++++++++++ .../processors/directory-scanner-factory.ts | 22 ++ .../processors/file-watcher-factory.ts | 27 ++ .../processors/get-embedding-batch-size.ts | 17 ++ src/services/code-index/service-factory.ts | 259 +++--------------- .../__tests__/vector-store-factory.spec.ts | 76 +++++ .../vector-store/vector-store-factory.ts | 45 +++ 24 files changed, 903 insertions(+), 284 deletions(-) create mode 100644 src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts create mode 100644 src/services/code-index/embedders/embedder-factory.ts create mode 100644 src/services/code-index/embedders/embedder-validation-manager.ts create mode 100644 src/services/code-index/embedders/factories/bedrock-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/gemini-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/mistral-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/ollama-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openai-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openrouter-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/require-setting.ts create mode 100644 src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts create mode 100644 src/services/code-index/interfaces/directory-scanner-factory.ts create mode 100644 src/services/code-index/interfaces/embedder-factory.ts create mode 100644 src/services/code-index/interfaces/file-watcher-factory.ts create mode 100644 src/services/code-index/interfaces/vector-store-factory.ts create mode 100644 src/services/code-index/processors/__tests__/processor-factories.spec.ts create mode 100644 src/services/code-index/processors/directory-scanner-factory.ts create mode 100644 src/services/code-index/processors/file-watcher-factory.ts create mode 100644 src/services/code-index/processors/get-embedding-batch-size.ts create mode 100644 src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts create mode 100644 src/services/code-index/vector-store/vector-store-factory.ts diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index d264d93fd5..bc1d619af2 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -1,5 +1,7 @@ import type { MockedClass, MockedFunction } from "vitest" import { CodeIndexServiceFactory } from "../service-factory" +import { EmbedderFactory } from "../embedders/embedder-factory" +import { VectorStoreFactory } from "../vector-store/vector-store-factory" import { OpenAiEmbedder } from "../embedders/openai" import { CodeIndexOllamaEmbedder } from "../embedders/ollama" import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible" @@ -9,6 +11,7 @@ import { MistralEmbedder } from "../embedders/mistral" import { VercelAiGatewayEmbedder } from "../embedders/vercel-ai-gateway" import { BedrockEmbedder } from "../embedders/bedrock" import { OpenRouterEmbedder } from "../embedders/openrouter" +import type { CodeIndexConfig } from "../interfaces/config" import { clearAllMocks } from "../../../test-utils/reset" @@ -56,11 +59,15 @@ const mockGetModelDimension = getModelDimension as MockedFunction { let factory: CodeIndexServiceFactory + let embedderFactory: EmbedderFactory + let vectorStoreFactory: VectorStoreFactory let mockConfigManager: any let mockCacheManager: any beforeEach(() => { clearAllMocks() + embedderFactory = new EmbedderFactory() + vectorStoreFactory = new VectorStoreFactory() mockConfigManager = { getConfig: vitest.fn(), @@ -72,6 +79,110 @@ describe("CodeIndexServiceFactory", () => { }) describe("createEmbedder", () => { + it("uses the current configuration and creates a fresh embedder on every call", () => { + const config: CodeIndexConfig = { + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + openAiOptions: { openAiNativeApiKey: "test-key" }, + modelId: "first-model", + } + mockConfigManager.getConfig.mockReturnValueOnce(config).mockReturnValueOnce({ + ...config, + modelId: "second-model", + }) + + const first = embedderFactory.create(mockConfigManager.getConfig()) + const second = embedderFactory.create(mockConfigManager.getConfig()) + + expect(first).not.toBe(second) + expect(MockedOpenAiEmbedder).toHaveBeenNthCalledWith(1, { + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "first-model", + }) + expect(MockedOpenAiEmbedder).toHaveBeenNthCalledWith(2, { + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "second-model", + }) + }) + + const requiredSettings = [ + ["openai", "openAiOptions", "openAiNativeApiKey", "openAiConfigMissing"], + ["ollama", "ollamaOptions", "ollamaBaseUrl", "ollamaConfigMissing"], + ["gemini", "geminiOptions", "apiKey", "geminiConfigMissing"], + ["mistral", "mistralOptions", "apiKey", "mistralConfigMissing"], + ["vercel-ai-gateway", "vercelAiGatewayOptions", "apiKey", "vercelAiGatewayConfigMissing"], + ["bedrock", "bedrockOptions", "region", "bedrockConfigMissing"], + ["openrouter", "openRouterOptions", "apiKey", "openRouterConfigMissing"], + ] as const + + it.each(requiredSettings)("rejects missing options for %s (%s.%s)", (provider, _options, _setting, error) => { + mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + }) + + it.each(requiredSettings)("rejects empty %s setting %s.%s", (provider, options, setting, error) => { + mockConfigManager.getConfig.mockReturnValue({ + embedderProvider: provider, + [options]: { baseUrl: "https://example.com", apiKey: "test-key", [setting]: "" }, + }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + }) + + it.each(["constructor", "toString", "__proto__"])("rejects prototype key %s as a provider", (provider) => { + mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) + }) + + it("preserves OpenAI options and overrides the embedded model without mutating config", () => { + const openAiOptions = Object.freeze({ + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "old-model", + openAiBaseUrl: "https://example.com/v1", + }) + const config: CodeIndexConfig = Object.freeze({ + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + modelId: "new-model", + openAiOptions, + }) + mockConfigManager.getConfig.mockReturnValue(config) + + const embedder = embedderFactory.create(mockConfigManager.getConfig()) + + expect(embedder).toBeInstanceOf(OpenAiEmbedder) + expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ + ...openAiOptions, + openAiEmbeddingModelId: "new-model", + }) + expect(openAiOptions.openAiEmbeddingModelId).toBe("old-model") + }) + + it("preserves Ollama options and overrides the embedded model without mutating config", () => { + const ollamaOptions = Object.freeze({ + ollamaBaseUrl: "http://localhost:11434", + ollamaModelId: "old-model", + ollamaApiKey: "test-key", + }) + const config: CodeIndexConfig = Object.freeze({ + isConfigured: true, + embedderProvider: providerIdentifiers.ollama, + modelId: "new-model", + ollamaOptions, + }) + mockConfigManager.getConfig.mockReturnValue(config) + + const embedder = embedderFactory.create(mockConfigManager.getConfig()) + + expect(embedder).toBeInstanceOf(CodeIndexOllamaEmbedder) + expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ ...ollamaOptions, ollamaModelId: "new-model" }) + expect(ollamaOptions.ollamaModelId).toBe("old-model") + }) + it("should pass model ID to OpenAI embedder when using OpenAI provider", () => { // Arrange const testModelId = "text-embedding-3-large" @@ -85,7 +196,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -107,7 +218,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -128,7 +239,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -149,7 +260,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -170,7 +281,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openAiConfigMissing", + ) }) it("should throw error when Ollama base URL is missing", () => { @@ -185,7 +298,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.ollamaConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.ollamaConfigMissing", + ) }) it("should pass model ID to OpenAI Compatible embedder when using OpenAI Compatible provider", () => { @@ -202,7 +317,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -225,7 +340,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -235,36 +350,36 @@ describe("CodeIndexServiceFactory", () => { ) }) - it("should throw error when OpenAI Compatible base URL is missing", () => { + it.each([undefined, ""])("rejects missing or empty OpenAI Compatible base URL: %s", (baseUrl) => { // Arrange const testConfig = { embedderProvider: "openai-compatible", modelId: "text-embedding-3-large", openAiCompatibleOptions: { - baseUrl: undefined, + baseUrl, apiKey: "test-api-key", }, } mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") }) - it("should throw error when OpenAI Compatible API key is missing", () => { + it.each([undefined, ""])("rejects missing or empty OpenAI Compatible API key: %s", (apiKey) => { // Arrange const testConfig = { embedderProvider: "openai-compatible", modelId: "text-embedding-3-large", openAiCompatibleOptions: { baseUrl: "https://api.example.com/v1", - apiKey: undefined, + apiKey, }, } mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.apiKeyRequired") }) it("should throw error when OpenAI Compatible options are missing", () => { @@ -277,7 +392,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") }) it("should create GeminiEmbedder with default model when no modelId specified", () => { @@ -291,7 +406,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined) @@ -309,7 +424,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") @@ -328,7 +443,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") @@ -345,7 +460,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.geminiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.geminiConfigMissing", + ) }) it("should throw error when Gemini options are missing", () => { @@ -357,7 +474,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.geminiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.geminiConfigMissing", + ) }) it("should pass model ID to Mistral embedder when using Mistral provider", () => { @@ -373,7 +492,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", testModelId) @@ -391,7 +510,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.mistralConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.mistralConfigMissing", + ) }) it("should handle undefined model ID for Mistral embedder", () => { @@ -406,7 +527,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", undefined) @@ -425,7 +546,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", testModelId) @@ -443,7 +564,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.vercelAiGatewayConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.vercelAiGatewayConfigMissing", + ) }) it("should handle undefined model ID for Vercel AI Gateway embedder", () => { @@ -458,7 +581,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", undefined) @@ -478,7 +601,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedBedrockEmbedder).toHaveBeenCalledWith("eu-west-1", "test-profile", testModelId) @@ -497,7 +620,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — profile omitted so the embedder falls back to the default credential chain. expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", undefined, testModelId) @@ -516,7 +639,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.bedrockConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.bedrockConfigMissing", + ) }) it("should handle undefined model ID for Bedrock embedder", () => { @@ -532,7 +657,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", "test-profile", undefined) @@ -552,7 +677,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -576,7 +701,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — specificProvider omitted so the embedder routes to its default provider. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -599,7 +724,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openRouterConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openRouterConfigMissing", + ) }) it("should handle undefined model ID for OpenRouter embedder", () => { @@ -615,7 +742,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith("test-openrouter-key", undefined, undefined, "openai") @@ -630,7 +757,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) }) it("should throw when provider is semble (semble handles its own embedding)", () => { @@ -639,7 +768,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => factory.createEmbedder()).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( "Semble provider handles its own embedding. Do not call createEmbedder() for semble", ) }) @@ -664,7 +793,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", testModelId) @@ -689,7 +818,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("ollama", testModelId) @@ -714,7 +843,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -746,7 +875,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(modelDimension) // This should be used // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -777,7 +906,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Model has no built-in dimension // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -806,7 +935,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -836,7 +965,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -858,7 +987,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -875,7 +1004,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001") @@ -899,7 +1028,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetDefaultModelId).toHaveBeenCalledWith("gemini") @@ -924,7 +1053,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "default-model") @@ -948,7 +1077,9 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + "serviceFactory.vectorDimensionNotDetermined", + ) }) it("should throw error when Qdrant URL is missing", () => { @@ -963,7 +1094,9 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act & Assert - expect(() => factory.createVectorStore()).toThrow("serviceFactory.qdrantUrlMissing") + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + "serviceFactory.qdrantUrlMissing", + ) }) it("should throw when provider is semble (semble handles its own vector storage)", () => { @@ -972,7 +1105,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "Semble provider handles its own vector storage. Do not call createVectorStore() for semble", ) }) @@ -1003,7 +1136,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1030,7 +1163,7 @@ describe("CodeIndexServiceFactory", () => { }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1056,7 +1189,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1081,7 +1214,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1104,7 +1237,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1129,7 +1262,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockRejectedValue(networkError) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1154,7 +1287,7 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder await expect(async () => { - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) await factory.validateEmbedder(embedder) }).rejects.toThrow("serviceFactory.openAiConfigMissing") }) @@ -1169,7 +1302,9 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder - expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) }) }) }) diff --git a/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts new file mode 100644 index 0000000000..6ff11f8fa7 --- /dev/null +++ b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts @@ -0,0 +1,59 @@ +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { t } from "../../../../i18n" +import embeddings from "../../../../i18n/locales/en/embeddings.json" +import { OpenAiEmbedder } from "../openai" +import { EmbedderValidationManager } from "../embedder-validation-manager" + +vi.mock("../openai") +vi.mock("../../../../i18n", () => ({ t: vi.fn() })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { instance: { captureEvent: vi.fn() } }, +})) + +describe("EmbedderValidationManager", () => { + const manager = new EmbedderValidationManager() + + beforeEach(() => vi.clearAllMocks()) + + it.each([{ valid: true }, { valid: false, error: "Invalid credentials" }])( + "preserves the validation result %j without reporting an exception", + async (result) => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + vi.mocked(embedder.validateConfiguration).mockResolvedValue(result) + + expect(await manager.validateEmbedder(embedder)).toBe(result) + expect(embedder.validateConfiguration).toHaveBeenCalledExactlyOnceWith() + expect(TelemetryService.instance.captureEvent).not.toHaveBeenCalled() + }, + ) + + it("preserves an exception's message and reports its stack", async () => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + const error = new Error("Connection failed") + vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) + + expect(await manager.validateEmbedder(embedder)).toEqual({ valid: false, error: error.message }) + expect(t).not.toHaveBeenCalled() + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( + TelemetryEventName.CODE_INDEX_ERROR, + { error: error.message, stack: error.stack, location: "validateEmbedder" }, + ) + }) + + it.each(["unavailable", null, undefined])("handles non-Error rejection %s", async (error) => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) + vi.mocked(t).mockReturnValue(embeddings.validation.configurationError) + + expect(await manager.validateEmbedder(embedder)).toEqual({ + valid: false, + error: embeddings.validation.configurationError, + }) + expect(t).toHaveBeenCalledExactlyOnceWith("embeddings:validation.configurationError") + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( + TelemetryEventName.CODE_INDEX_ERROR, + { error: String(error), stack: undefined, location: "validateEmbedder" }, + ) + }) +}) diff --git a/src/services/code-index/embedders/embedder-factory.ts b/src/services/code-index/embedders/embedder-factory.ts new file mode 100644 index 0000000000..31d6e8a754 --- /dev/null +++ b/src/services/code-index/embedders/embedder-factory.ts @@ -0,0 +1,51 @@ +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { t } from "../../../i18n" +import type { CodeIndexConfig } from "../interfaces/config" +import type { IEmbedder } from "../interfaces" +import type { IEmbedderFactory } from "../interfaces/embedder-factory" +import { OpenAiEmbedderFactory } from "./factories/openai-embedder-factory" +import { OllamaEmbedderFactory } from "./factories/ollama-embedder-factory" +import { OpenAICompatibleEmbedderFactory } from "./factories/openai-compatible-embedder-factory" +import { GeminiEmbedderFactory } from "./factories/gemini-embedder-factory" +import { MistralEmbedderFactory } from "./factories/mistral-embedder-factory" +import { VercelAiGatewayEmbedderFactory } from "./factories/vercel-ai-gateway-embedder-factory" +import { BedrockEmbedderFactory } from "./factories/bedrock-embedder-factory" +import { OpenRouterEmbedderFactory } from "./factories/openrouter-embedder-factory" + +type VectorEmbedderProvider = Exclude + +export class EmbedderFactory implements IEmbedderFactory { + // Each provider owns its constructor arguments and validation. The record requires + // an entry for every vector embedder provider when the supported union changes. + // TODO: Inject the provider factory registry via DI instead of constructing factories here. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + private readonly embedderFactories: Readonly> = { + [providerIdentifiers.openai]: new OpenAiEmbedderFactory(), + [providerIdentifiers.ollama]: new OllamaEmbedderFactory(), + "openai-compatible": new OpenAICompatibleEmbedderFactory(), + [providerIdentifiers.gemini]: new GeminiEmbedderFactory(), + [providerIdentifiers.mistral]: new MistralEmbedderFactory(), + [providerIdentifiers.vercelAiGateway]: new VercelAiGatewayEmbedderFactory(), + [providerIdentifiers.bedrock]: new BedrockEmbedderFactory(), + [providerIdentifiers.openrouter]: new OpenRouterEmbedderFactory(), + } + + public create(config: CodeIndexConfig): IEmbedder { + const provider = config.embedderProvider + + // Semble is a self-contained search binary, not a vector embedder. + if (provider === "semble") { + throw new Error( + "Semble provider handles its own embedding. Do not call createEmbedder() for semble — use SembleProvider instead.", + ) + } + + // Keep a runtime guard for invalid persisted configuration, including prototype keys. + if (!Object.prototype.hasOwnProperty.call(this.embedderFactories, provider)) { + throw new Error(t("embeddings:serviceFactory.invalidEmbedderType", { embedderProvider: provider })) + } + + return this.embedderFactories[provider].create(config) + } +} diff --git a/src/services/code-index/embedders/embedder-validation-manager.ts b/src/services/code-index/embedders/embedder-validation-manager.ts new file mode 100644 index 0000000000..84dd73aac6 --- /dev/null +++ b/src/services/code-index/embedders/embedder-validation-manager.ts @@ -0,0 +1,23 @@ +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { t } from "../../../i18n" +import type { IEmbedder } from "../interfaces" + +export class EmbedderValidationManager { + public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { + try { + return await embedder.validateConfiguration() + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "validateEmbedder", + }) + + return { + valid: false, + error: error instanceof Error ? error.message : t("embeddings:validation.configurationError"), + } + } + } +} diff --git a/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts new file mode 100644 index 0000000000..5a93c5619b --- /dev/null +++ b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts @@ -0,0 +1,14 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { BedrockEmbedder } from "../bedrock" +import { requireSetting } from "./require-setting" + +export class BedrockEmbedderFactory implements IEmbedderFactory { + create({ bedrockOptions, modelId }: CodeIndexConfig): BedrockEmbedder { + return new BedrockEmbedder( + requireSetting(bedrockOptions?.region, "embeddings:serviceFactory.bedrockConfigMissing"), + bedrockOptions?.profile, + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/gemini-embedder-factory.ts b/src/services/code-index/embedders/factories/gemini-embedder-factory.ts new file mode 100644 index 0000000000..71cd2018aa --- /dev/null +++ b/src/services/code-index/embedders/factories/gemini-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { GeminiEmbedder } from "../gemini" +import { requireSetting } from "./require-setting" + +export class GeminiEmbedderFactory implements IEmbedderFactory { + create({ geminiOptions, modelId }: CodeIndexConfig): GeminiEmbedder { + return new GeminiEmbedder( + requireSetting(geminiOptions?.apiKey, "embeddings:serviceFactory.geminiConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/mistral-embedder-factory.ts b/src/services/code-index/embedders/factories/mistral-embedder-factory.ts new file mode 100644 index 0000000000..38fa041a8b --- /dev/null +++ b/src/services/code-index/embedders/factories/mistral-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { MistralEmbedder } from "../mistral" +import { requireSetting } from "./require-setting" + +export class MistralEmbedderFactory implements IEmbedderFactory { + create({ mistralOptions, modelId }: CodeIndexConfig): MistralEmbedder { + return new MistralEmbedder( + requireSetting(mistralOptions?.apiKey, "embeddings:serviceFactory.mistralConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/ollama-embedder-factory.ts b/src/services/code-index/embedders/factories/ollama-embedder-factory.ts new file mode 100644 index 0000000000..4329cbbf42 --- /dev/null +++ b/src/services/code-index/embedders/factories/ollama-embedder-factory.ts @@ -0,0 +1,17 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { CodeIndexOllamaEmbedder } from "../ollama" +import { requireSetting } from "./require-setting" + +export class OllamaEmbedderFactory implements IEmbedderFactory { + create({ ollamaOptions, modelId }: CodeIndexConfig): CodeIndexOllamaEmbedder { + return new CodeIndexOllamaEmbedder({ + ...ollamaOptions, + ollamaBaseUrl: requireSetting( + ollamaOptions?.ollamaBaseUrl, + "embeddings:serviceFactory.ollamaConfigMissing", + ), + ollamaModelId: modelId, + }) + } +} diff --git a/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts new file mode 100644 index 0000000000..827e171696 --- /dev/null +++ b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts @@ -0,0 +1,14 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { requireSetting } from "./require-setting" + +export class OpenAICompatibleEmbedderFactory implements IEmbedderFactory { + create({ openAiCompatibleOptions, modelId }: CodeIndexConfig): OpenAICompatibleEmbedder { + return new OpenAICompatibleEmbedder( + requireSetting(openAiCompatibleOptions?.baseUrl, "embeddings:validation.baseUrlRequired"), + requireSetting(openAiCompatibleOptions?.apiKey, "embeddings:validation.apiKeyRequired"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/openai-embedder-factory.ts b/src/services/code-index/embedders/factories/openai-embedder-factory.ts new file mode 100644 index 0000000000..e5d0cba5db --- /dev/null +++ b/src/services/code-index/embedders/factories/openai-embedder-factory.ts @@ -0,0 +1,17 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenAiEmbedder } from "../openai" +import { requireSetting } from "./require-setting" + +export class OpenAiEmbedderFactory implements IEmbedderFactory { + create({ openAiOptions, modelId }: CodeIndexConfig): OpenAiEmbedder { + return new OpenAiEmbedder({ + ...openAiOptions, + openAiNativeApiKey: requireSetting( + openAiOptions?.openAiNativeApiKey, + "embeddings:serviceFactory.openAiConfigMissing", + ), + openAiEmbeddingModelId: modelId, + }) + } +} diff --git a/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts b/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts new file mode 100644 index 0000000000..7ec8338aa9 --- /dev/null +++ b/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts @@ -0,0 +1,15 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenRouterEmbedder } from "../openrouter" +import { requireSetting } from "./require-setting" + +export class OpenRouterEmbedderFactory implements IEmbedderFactory { + create({ openRouterOptions, modelId }: CodeIndexConfig): OpenRouterEmbedder { + return new OpenRouterEmbedder( + requireSetting(openRouterOptions?.apiKey, "embeddings:serviceFactory.openRouterConfigMissing"), + modelId, + undefined, // maxItemTokens + openRouterOptions?.specificProvider, + ) + } +} diff --git a/src/services/code-index/embedders/factories/require-setting.ts b/src/services/code-index/embedders/factories/require-setting.ts new file mode 100644 index 0000000000..56c31dad62 --- /dev/null +++ b/src/services/code-index/embedders/factories/require-setting.ts @@ -0,0 +1,8 @@ +import { t } from "../../../../i18n" + +export function requireSetting(value: string | undefined, errorKey: string): string { + if (!value) { + throw new Error(t(errorKey)) + } + return value +} diff --git a/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts b/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts new file mode 100644 index 0000000000..1f340b3a21 --- /dev/null +++ b/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway" +import { requireSetting } from "./require-setting" + +export class VercelAiGatewayEmbedderFactory implements IEmbedderFactory { + create({ vercelAiGatewayOptions, modelId }: CodeIndexConfig): VercelAiGatewayEmbedder { + return new VercelAiGatewayEmbedder( + requireSetting(vercelAiGatewayOptions?.apiKey, "embeddings:serviceFactory.vercelAiGatewayConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/interfaces/directory-scanner-factory.ts b/src/services/code-index/interfaces/directory-scanner-factory.ts new file mode 100644 index 0000000000..0e55a220bb --- /dev/null +++ b/src/services/code-index/interfaces/directory-scanner-factory.ts @@ -0,0 +1,15 @@ +import type { Ignore } from "ignore" +import type { CacheManager } from "../cache-manager" +import type { ICodeParser, IDirectoryScanner, IEmbedder, IVectorStore } from "./index" + +export interface DirectoryScannerFactoryOptions { + embedder: IEmbedder + vectorStore: IVectorStore + parser: ICodeParser + cacheManager: CacheManager + ignoreInstance: Ignore +} + +export interface IDirectoryScannerFactory { + create(options: DirectoryScannerFactoryOptions): IDirectoryScanner +} diff --git a/src/services/code-index/interfaces/embedder-factory.ts b/src/services/code-index/interfaces/embedder-factory.ts new file mode 100644 index 0000000000..013b3f372b --- /dev/null +++ b/src/services/code-index/interfaces/embedder-factory.ts @@ -0,0 +1,7 @@ +import type { CodeIndexConfig } from "./config" +import type { IEmbedder } from "./index" + +/** Creates a vector embedder, validating the provider's required configuration. */ +export interface IEmbedderFactory { + create(config: CodeIndexConfig): IEmbedder +} diff --git a/src/services/code-index/interfaces/file-watcher-factory.ts b/src/services/code-index/interfaces/file-watcher-factory.ts new file mode 100644 index 0000000000..94fc8882fc --- /dev/null +++ b/src/services/code-index/interfaces/file-watcher-factory.ts @@ -0,0 +1,19 @@ +import type * as vscode from "vscode" +import type { Ignore } from "ignore" +import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" +import type { CacheManager } from "../cache-manager" +import type { IEmbedder, IFileWatcher, IVectorStore } from "./index" + +export interface FileWatcherFactoryOptions { + workspacePath: string + context: vscode.ExtensionContext + embedder: IEmbedder + vectorStore: IVectorStore + cacheManager: CacheManager + ignoreInstance: Ignore + rooIgnoreController?: RooIgnoreController +} + +export interface IFileWatcherFactory { + create(options: FileWatcherFactoryOptions): IFileWatcher +} diff --git a/src/services/code-index/interfaces/vector-store-factory.ts b/src/services/code-index/interfaces/vector-store-factory.ts new file mode 100644 index 0000000000..4a4361ef35 --- /dev/null +++ b/src/services/code-index/interfaces/vector-store-factory.ts @@ -0,0 +1,6 @@ +import type { CodeIndexConfig } from "./config" +import type { IVectorStore } from "./index" + +export interface IVectorStoreFactory { + create(config: CodeIndexConfig, workspacePath: string): IVectorStore +} diff --git a/src/services/code-index/processors/__tests__/processor-factories.spec.ts b/src/services/code-index/processors/__tests__/processor-factories.spec.ts new file mode 100644 index 0000000000..4af92c14cd --- /dev/null +++ b/src/services/code-index/processors/__tests__/processor-factories.spec.ts @@ -0,0 +1,186 @@ +import * as vscode from "vscode" +import ignore from "ignore" +import { makeExtensionContext } from "../../../../test-utils/vscode" +import { Package } from "../../../../shared/package" +import { CacheManager } from "../../cache-manager" +import { BATCH_SEGMENT_THRESHOLD } from "../../constants" +import { OpenAiEmbedder } from "../../embedders/openai" +import { QdrantVectorStore } from "../../vector-store/qdrant-client" +import { CodeIndexServiceFactory } from "../../service-factory" +import { CodeIndexConfigManager } from "../../config-manager" +import { EmbedderFactory } from "../../embedders/embedder-factory" +import { VectorStoreFactory } from "../../vector-store/vector-store-factory" +import type { CodeIndexConfig } from "../../interfaces/config" +import { DirectoryScannerFactory } from "../directory-scanner-factory" +import { FileWatcherFactory } from "../file-watcher-factory" +import { getEmbeddingBatchSize } from "../get-embedding-batch-size" +import { codeParser } from "../parser" +import { DirectoryScanner } from "../scanner" +import { FileWatcher } from "../file-watcher" + +vi.mock("vscode", () => ({ + workspace: { getConfiguration: vi.fn(() => ({ get: vi.fn() })) }, +})) +vi.mock("../../cache-manager") +vi.mock("../../embedders/openai") +vi.mock("../../vector-store/qdrant-client") +vi.mock("../../config-manager") +vi.mock("../parser", () => ({ codeParser: { parseFile: vi.fn() } })) +vi.mock("../scanner") +vi.mock("../file-watcher") + +describe("processor factories", () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.restoreAllMocks()) + + function dependencies() { + const context = makeExtensionContext() + return { + context, + workspacePath: "/workspace", + cacheManager: new CacheManager(context, "/workspace"), + embedder: new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }), + vectorStore: new QdrantVectorStore("/workspace", "http://localhost:6333", 512), + parser: codeParser, + ignoreInstance: ignore(), + } + } + + it.each([32, BATCH_SEGMENT_THRESHOLD])( + "passes batch size %s and dependencies to both constructors", + (batchSize) => { + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockReturnValue(batchSize) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + const options = dependencies() + + const scanner = new DirectoryScannerFactory().create(options) + const watcher = new FileWatcherFactory().create(options) + + expect(scanner).toBeInstanceOf(DirectoryScanner) + expect(watcher).toBeInstanceOf(FileWatcher) + expect(DirectoryScanner).toHaveBeenCalledWith( + options.embedder, + options.vectorStore, + options.parser, + options.cacheManager, + options.ignoreInstance, + batchSize, + ) + expect(FileWatcher).toHaveBeenCalledWith( + options.workspacePath, + options.context, + options.cacheManager, + options.embedder, + options.vectorStore, + options.ignoreInstance, + undefined, + batchSize, + ) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name) + expect(configuration.get).toHaveBeenCalledWith("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) + }, + ) + + it("reads settings again for each creation and returns fresh instances", () => { + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get) + .mockReturnValueOnce(32) + .mockReturnValueOnce(64) + .mockReturnValueOnce(128) + .mockReturnValueOnce(256) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + const options = dependencies() + const scanners = new DirectoryScannerFactory() + const watchers = new FileWatcherFactory() + + expect(scanners.create(options)).not.toBe(scanners.create(options)) + expect(watchers.create(options)).not.toBe(watchers.create(options)) + expect(vi.mocked(DirectoryScanner).mock.calls.map((args) => args[5])).toEqual([32, 64]) + expect(vi.mocked(FileWatcher).mock.calls.map((args) => args[7])).toEqual([128, 256]) + }) + + it("uses the fallback when retrieving configuration throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = new Error("unavailable") + vi.mocked(vscode.workspace.getConfiguration).mockImplementationOnce(() => { + throw error + }) + expect(getEmbeddingBatchSize()).toBe(BATCH_SEGMENT_THRESHOLD) + expect(warn).toHaveBeenCalledExactlyOnceWith( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + }) + + it("uses the fallback when reading the setting throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = new Error("unavailable") + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockImplementationOnce(() => { + throw error + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + expect(getEmbeddingBatchSize()).toBe(BATCH_SEGMENT_THRESHOLD) + expect(warn).toHaveBeenCalledExactlyOnceWith( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + }) + + it("does not log a warning when reading the setting succeeds", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockReturnValue(32) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + + expect(getEmbeddingBatchSize()).toBe(32) + expect(warn).not.toHaveBeenCalled() + }) + + it("preserves service factory wiring, including the explicitly supplied watcher cache", () => { + const options = dependencies() + const watcherCache = new CacheManager(options.context, "/workspace") + const configManager = Object.create(CodeIndexConfigManager.prototype) as CodeIndexConfigManager + const config: CodeIndexConfig = { isConfigured: true, embedderProvider: "openai-compatible" } + Object.defineProperty(configManager, "isFeatureConfigured", { value: true }) + vi.mocked(configManager.getConfig).mockReturnValue(config) + const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create").mockReturnValue(options.embedder) + const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create").mockReturnValue(options.vectorStore) + const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + + const services = factory.createServices(options.context, watcherCache, options.ignoreInstance) + + expect(configManager.getConfig).toHaveBeenCalledTimes(1) + expect(createEmbedder).toHaveBeenCalledExactlyOnceWith(config) + expect(createVectorStore).toHaveBeenCalledExactlyOnceWith(config, options.workspacePath) + expect(services).toEqual({ + embedder: options.embedder, + vectorStore: options.vectorStore, + parser: codeParser, + scanner: vi.mocked(DirectoryScanner).mock.instances[0], + fileWatcher: vi.mocked(FileWatcher).mock.instances[0], + }) + expect(vi.mocked(DirectoryScanner).mock.calls[0][3]).toBe(options.cacheManager) + expect(vi.mocked(FileWatcher).mock.calls[0][2]).toBe(watcherCache) + expect(vi.mocked(FileWatcher).mock.calls[0][0]).toBe(options.workspacePath) + }) + + it("does not create services when indexing is not configured", () => { + const options = dependencies() + const configManager = Object.create(CodeIndexConfigManager.prototype) as CodeIndexConfigManager + Object.defineProperty(configManager, "isFeatureConfigured", { value: false }) + const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create") + const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create") + const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + + expect(() => factory.createServices(options.context, options.cacheManager, options.ignoreInstance)).toThrow( + "serviceFactory.codeIndexingNotConfigured", + ) + expect(configManager.getConfig).not.toHaveBeenCalled() + expect(createEmbedder).not.toHaveBeenCalled() + expect(createVectorStore).not.toHaveBeenCalled() + expect(DirectoryScanner).not.toHaveBeenCalled() + expect(FileWatcher).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/code-index/processors/directory-scanner-factory.ts b/src/services/code-index/processors/directory-scanner-factory.ts new file mode 100644 index 0000000000..099aa6d261 --- /dev/null +++ b/src/services/code-index/processors/directory-scanner-factory.ts @@ -0,0 +1,22 @@ +import type { DirectoryScannerFactoryOptions, IDirectoryScannerFactory } from "../interfaces/directory-scanner-factory" +import { DirectoryScanner } from "./scanner" +import { getEmbeddingBatchSize } from "./get-embedding-batch-size" + +export class DirectoryScannerFactory implements IDirectoryScannerFactory { + public create({ + embedder, + vectorStore, + parser, + cacheManager, + ignoreInstance, + }: DirectoryScannerFactoryOptions): DirectoryScanner { + return new DirectoryScanner( + embedder, + vectorStore, + parser, + cacheManager, + ignoreInstance, + getEmbeddingBatchSize(), + ) + } +} diff --git a/src/services/code-index/processors/file-watcher-factory.ts b/src/services/code-index/processors/file-watcher-factory.ts new file mode 100644 index 0000000000..2f4cc227b6 --- /dev/null +++ b/src/services/code-index/processors/file-watcher-factory.ts @@ -0,0 +1,27 @@ +import type { IFileWatcher } from "../interfaces" +import type { FileWatcherFactoryOptions, IFileWatcherFactory } from "../interfaces/file-watcher-factory" +import { FileWatcher } from "./file-watcher" +import { getEmbeddingBatchSize } from "./get-embedding-batch-size" + +export class FileWatcherFactory implements IFileWatcherFactory { + public create({ + workspacePath, + context, + cacheManager, + embedder, + vectorStore, + ignoreInstance, + rooIgnoreController, + }: FileWatcherFactoryOptions): IFileWatcher { + return new FileWatcher( + workspacePath, + context, + cacheManager, + embedder, + vectorStore, + ignoreInstance, + rooIgnoreController, + getEmbeddingBatchSize(), + ) + } +} diff --git a/src/services/code-index/processors/get-embedding-batch-size.ts b/src/services/code-index/processors/get-embedding-batch-size.ts new file mode 100644 index 0000000000..c5c5b28c6a --- /dev/null +++ b/src/services/code-index/processors/get-embedding-batch-size.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode" +import { Package } from "../../../shared/package" +import { BATCH_SEGMENT_THRESHOLD } from "../constants" + +export function getEmbeddingBatchSize(): number { + try { + return vscode.workspace + .getConfiguration(Package.name) + .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) + } catch (error) { + console.warn( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + return BATCH_SEGMENT_THRESHOLD + } +} diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index beab32ad02..d2bbe43f2a 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -1,249 +1,48 @@ -import * as vscode from "vscode" +import type * as vscode from "vscode" import { Ignore } from "ignore" -import type { EmbedderProvider } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName } from "@roo-code/types" - import { t } from "../../i18n" -import { getDefaultModelId, getModelDimension } from "../../shared/embeddingModels" -import { Package } from "../../shared/package" - import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" -import { OpenAiEmbedder } from "./embedders/openai" -import { CodeIndexOllamaEmbedder } from "./embedders/ollama" -import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible" -import { GeminiEmbedder } from "./embedders/gemini" -import { MistralEmbedder } from "./embedders/mistral" -import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway" -import { BedrockEmbedder } from "./embedders/bedrock" -import { OpenRouterEmbedder } from "./embedders/openrouter" -import { QdrantVectorStore } from "./vector-store/qdrant-client" -import { codeParser, DirectoryScanner, FileWatcher } from "./processors" +import { EmbedderFactory } from "./embedders/embedder-factory" +import { EmbedderValidationManager } from "./embedders/embedder-validation-manager" +import { VectorStoreFactory } from "./vector-store/vector-store-factory" +import { codeParser, DirectoryScanner } from "./processors" +import { DirectoryScannerFactory } from "./processors/directory-scanner-factory" +import { FileWatcherFactory } from "./processors/file-watcher-factory" import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" -import { BATCH_SEGMENT_THRESHOLD } from "./constants" -import { providerIdentifiers } from "@roo-code/types/provider-identifiers" /** * Factory class responsible for creating and configuring code indexing service dependencies. */ export class CodeIndexServiceFactory { + // TODO: Remove all factory and validation manager dependencies below once the workspace scope + // supplies them to CodeIndexManager via DI and it calls them directly. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + private readonly embedderFactory = new EmbedderFactory() + private readonly vectorStoreFactory = new VectorStoreFactory() + private readonly directoryScannerFactory = new DirectoryScannerFactory() + private readonly fileWatcherFactory = new FileWatcherFactory() + private readonly embedderValidationManager = new EmbedderValidationManager() + constructor( private readonly configManager: CodeIndexConfigManager, private readonly workspacePath: string, private readonly cacheManager: CacheManager, ) {} - /** - * Creates an embedder instance based on the current configuration. - */ - public createEmbedder(): IEmbedder { - const config = this.configManager.getConfig() - - const provider = config.embedderProvider as EmbedderProvider - - if (provider === "semble") { - // Semble is a self-contained search binary that doesn't produce embedding - // vectors. The manager branches before reaching this factory (see - // _recreateServices). This guard prevents accidental misuse if the factory - // is ever called directly with a semble config. - throw new Error( - "Semble provider handles its own embedding. Do not call createEmbedder() for semble — use SembleProvider instead.", - ) - } - - if (provider === providerIdentifiers.openai) { - const apiKey = config.openAiOptions?.openAiNativeApiKey - - if (!apiKey) { - throw new Error(t("embeddings:serviceFactory.openAiConfigMissing")) - } - return new OpenAiEmbedder({ - ...config.openAiOptions, - openAiEmbeddingModelId: config.modelId, - }) - } else if (provider === providerIdentifiers.ollama) { - if (!config.ollamaOptions?.ollamaBaseUrl) { - throw new Error(t("embeddings:serviceFactory.ollamaConfigMissing")) - } - return new CodeIndexOllamaEmbedder({ - ...config.ollamaOptions, - ollamaModelId: config.modelId, - }) - } else if (provider === "openai-compatible") { - if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.openAiCompatibleConfigMissing")) - } - return new OpenAICompatibleEmbedder( - config.openAiCompatibleOptions.baseUrl, - config.openAiCompatibleOptions.apiKey, - config.modelId, - ) - } else if (provider === providerIdentifiers.gemini) { - if (!config.geminiOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.geminiConfigMissing")) - } - return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.mistral) { - if (!config.mistralOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.mistralConfigMissing")) - } - return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.vercelAiGateway) { - if (!config.vercelAiGatewayOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.vercelAiGatewayConfigMissing")) - } - return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.bedrock) { - // Only region is required for Bedrock (profile is optional) - if (!config.bedrockOptions?.region) { - throw new Error(t("embeddings:serviceFactory.bedrockConfigMissing")) - } - return new BedrockEmbedder(config.bedrockOptions.region, config.bedrockOptions.profile, config.modelId) - } else if (provider === providerIdentifiers.openrouter) { - if (!config.openRouterOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.openRouterConfigMissing")) - } - return new OpenRouterEmbedder( - config.openRouterOptions.apiKey, - config.modelId, - undefined, // maxItemTokens - config.openRouterOptions.specificProvider, - ) - } - - throw new Error( - t("embeddings:serviceFactory.invalidEmbedderType", { embedderProvider: config.embedderProvider }), - ) - } - /** * Validates an embedder instance to ensure it's properly configured. * @param embedder The embedder instance to validate * @returns Promise resolving to validation result */ - public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { - try { - return await embedder.validateConfiguration() - } catch (error) { - // Capture telemetry for the error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "validateEmbedder", - }) - - // If validation throws an exception, preserve the original error message - return { - valid: false, - error: error instanceof Error ? error.message : "embeddings:validation.configurationError", - } - } - } - - /** - * Creates a vector store instance using the current configuration. - */ - public createVectorStore(): IVectorStore { - const config = this.configManager.getConfig() - - const provider = config.embedderProvider as EmbedderProvider - - if (provider === "semble") { - throw new Error( - "Semble provider handles its own vector storage. Do not call createVectorStore() for semble — use SembleProvider instead.", - ) - } - - const defaultModel = getDefaultModelId(provider) - // Use the embedding model ID from config, not the chat model IDs - const modelId = config.modelId ?? defaultModel - - let vectorSize: number | undefined - - // First try to get the model-specific dimension from profiles - vectorSize = getModelDimension(provider, modelId) - - // Only use manual dimension if model doesn't have a built-in dimension - if (!vectorSize && config.modelDimension && config.modelDimension > 0) { - vectorSize = config.modelDimension - } - - if (vectorSize === undefined || vectorSize <= 0) { - if (provider === "openai-compatible") { - throw new Error( - t("embeddings:serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", { modelId, provider }), - ) - } else { - throw new Error(t("embeddings:serviceFactory.vectorDimensionNotDetermined", { modelId, provider })) - } - } - - if (!config.qdrantUrl) { - throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) - } - - // Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?) - return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) - } - - /** - * Creates a directory scanner instance with its required dependencies. - */ - public createDirectoryScanner( - embedder: IEmbedder, - vectorStore: IVectorStore, - parser: ICodeParser, - ignoreInstance: Ignore, - ): DirectoryScanner { - // Get the configurable batch size from VSCode settings - let batchSize: number - try { - batchSize = vscode.workspace - .getConfiguration(Package.name) - .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) - } catch { - // In test environment, vscode.workspace might not be available - batchSize = BATCH_SEGMENT_THRESHOLD - } - return new DirectoryScanner(embedder, vectorStore, parser, this.cacheManager, ignoreInstance, batchSize) - } - - /** - * Creates a file watcher instance with its required dependencies. - */ - public createFileWatcher( - context: vscode.ExtensionContext, - embedder: IEmbedder, - vectorStore: IVectorStore, - cacheManager: CacheManager, - ignoreInstance: Ignore, - rooIgnoreController?: RooIgnoreController, - ): IFileWatcher { - // Get the configurable batch size from VSCode settings - let batchSize: number - try { - batchSize = vscode.workspace - .getConfiguration(Package.name) - .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) - } catch { - // In test environment, vscode.workspace might not be available - batchSize = BATCH_SEGMENT_THRESHOLD - } - return new FileWatcher( - this.workspacePath, - context, - cacheManager, - embedder, - vectorStore, - ignoreInstance, - rooIgnoreController, - batchSize, - ) + // TODO: Remove this proxy and have CodeIndexManager call the injected validation manager directly. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + public validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { + return this.embedderValidationManager.validateEmbedder(embedder) } /** @@ -266,18 +65,26 @@ export class CodeIndexServiceFactory { throw new Error(t("embeddings:serviceFactory.codeIndexingNotConfigured")) } - const embedder = this.createEmbedder() - const vectorStore = this.createVectorStore() + const config = this.configManager.getConfig() + const embedder = this.embedderFactory.create(config) + const vectorStore = this.vectorStoreFactory.create(config, this.workspacePath) const parser = codeParser - const scanner = this.createDirectoryScanner(embedder, vectorStore, parser, ignoreInstance) - const fileWatcher = this.createFileWatcher( + const scanner = this.directoryScannerFactory.create({ + embedder, + vectorStore, + parser, + cacheManager: this.cacheManager, + ignoreInstance, + }) + const fileWatcher = this.fileWatcherFactory.create({ + workspacePath: this.workspacePath, context, embedder, vectorStore, cacheManager, ignoreInstance, rooIgnoreController, - ) + }) return { embedder, diff --git a/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts new file mode 100644 index 0000000000..3a826826d4 --- /dev/null +++ b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts @@ -0,0 +1,76 @@ +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { getDefaultModelId, getModelDimension } from "../../../../shared/embeddingModels" +import type { CodeIndexConfig } from "../../interfaces/config" +import { QdrantVectorStore } from "../qdrant-client" +import { VectorStoreFactory } from "../vector-store-factory" + +vi.mock("../qdrant-client") +vi.mock("../../../../shared/embeddingModels", () => ({ + getDefaultModelId: vi.fn(), + getModelDimension: vi.fn(), +})) + +describe("VectorStoreFactory", () => { + let factory: VectorStoreFactory + let config: CodeIndexConfig + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getDefaultModelId).mockReturnValue("default-model") + vi.mocked(getModelDimension).mockReturnValue(undefined) + factory = new VectorStoreFactory() + config = { + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + modelId: "custom-model", + modelDimension: 512, + qdrantUrl: "http://localhost:6333", + } + }) + + it.each([undefined, 0])("uses the manual dimension when the built-in dimension is %s", (dimension) => { + vi.mocked(getModelDimension).mockReturnValue(dimension) + + expect(factory.create(config, "/workspace")).toBeInstanceOf(QdrantVectorStore) + expect(QdrantVectorStore).toHaveBeenCalledWith("/workspace", config.qdrantUrl, 512, undefined) + }) + + it("does not override an invalid negative built-in dimension with the manual dimension", () => { + vi.mocked(getModelDimension).mockReturnValue(-1) + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it.each([undefined, 0, -1])("rejects an unavailable or invalid manual dimension: %s", (dimension) => { + config.modelDimension = dimension + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it("rejects Semble before resolving dimensions or creating a store", () => { + config.embedderProvider = "semble" + + expect(() => factory.create(config, "/workspace")).toThrow("Semble provider handles its own vector storage") + expect(getModelDimension).not.toHaveBeenCalled() + expect(getDefaultModelId).not.toHaveBeenCalled() + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it("uses current configuration and workspace on every call without caching stores", () => { + const first = factory.create(config, "/first") + const second = factory.create({ ...config, modelDimension: 1024 }, "/second") + + expect(first).not.toBe(second) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(1, "/first", config.qdrantUrl, 512, undefined) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(2, "/second", config.qdrantUrl, 1024, undefined) + }) + + it("reports the dimension error before a missing Qdrant URL", () => { + config.modelDimension = undefined + config.qdrantUrl = undefined + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + }) +}) diff --git a/src/services/code-index/vector-store/vector-store-factory.ts b/src/services/code-index/vector-store/vector-store-factory.ts new file mode 100644 index 0000000000..174f217108 --- /dev/null +++ b/src/services/code-index/vector-store/vector-store-factory.ts @@ -0,0 +1,45 @@ +import { t } from "../../../i18n" +import { getDefaultModelId, getModelDimension } from "../../../shared/embeddingModels" +import type { CodeIndexConfig } from "../interfaces/config" +import type { IVectorStore } from "../interfaces" +import type { IVectorStoreFactory } from "../interfaces/vector-store-factory" +import { QdrantVectorStore } from "./qdrant-client" + +export class VectorStoreFactory implements IVectorStoreFactory { + public create(config: CodeIndexConfig, workspacePath: string): IVectorStore { + if (config.embedderProvider === "semble") { + throw new Error( + "Semble provider handles its own vector storage. Do not call createVectorStore() for semble — use SembleProvider instead.", + ) + } + + const vectorSize = this.resolveVectorSize(config) + + if (!config.qdrantUrl) { + throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) + } + + return new QdrantVectorStore(workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) + } + + private resolveVectorSize(config: CodeIndexConfig): number { + const provider = config.embedderProvider + const modelId = config.modelId ?? getDefaultModelId(provider) + let vectorSize = getModelDimension(provider, modelId) + + // A manual dimension is only a fallback for models without a built-in dimension. + if (!vectorSize && config.modelDimension && config.modelDimension > 0) { + vectorSize = config.modelDimension + } + + if (vectorSize === undefined || vectorSize <= 0) { + const errorKey = + provider === "openai-compatible" + ? "embeddings:serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible" + : "embeddings:serviceFactory.vectorDimensionNotDetermined" + throw new Error(t(errorKey, { modelId, provider })) + } + + return vectorSize + } +} From d0dec4b124f9103920b3772a443de743b8d299c2 Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:09:39 +0000 Subject: [PATCH 06/35] fix(task): keep the first abort reason (RSK-19) (#1811) * fix(task): keep the first abort reason * fix(task): apply first-reason-wins to both abort paths in catch block --- src/core/task/Task.ts | 7 +- .../__tests__/Task.abort-reason-race.spec.ts | 216 ++++++++++++++++++ src/core/webview/ClineProvider.ts | 30 +-- .../ClineProvider.taskHistory.spec.ts | 19 ++ 4 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 src/core/task/__tests__/Task.abort-reason-race.spec.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 88bd8141b8..47e9e2ec37 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3715,8 +3715,8 @@ export class Task extends EventEmitter implements TaskLike { await abortStream(cancelReason, streamingFailedMessage) if (this.abort) { - // User cancelled - abort the entire task - this.abortReason = cancelReason + // ??= keeps the first reason; a cancel can land during abortStream after cancelReason was already computed. + this.abortReason ??= "user_cancelled" await this.abortTask() } else if (error instanceof OutputTokenLimitError) { // Truncation repeats on an identical request, so never auto-retry it @@ -3751,8 +3751,7 @@ export class Task extends EventEmitter implements TaskLike { console.log( `[Task#${this.taskId}.${this.instanceId}] Task aborted during mid-stream retry backoff`, ) - // Abort the entire task - this.abortReason = "user_cancelled" + this.abortReason ??= "user_cancelled" await this.abortTask() break } diff --git a/src/core/task/__tests__/Task.abort-reason-race.spec.ts b/src/core/task/__tests__/Task.abort-reason-race.spec.ts new file mode 100644 index 0000000000..7b71ee1dd6 --- /dev/null +++ b/src/core/task/__tests__/Task.abort-reason-race.spec.ts @@ -0,0 +1,216 @@ +// npx vitest run core/task/__tests__/Task.abort-reason-race.spec.ts + +import * as os from "os" +import * as path from "path" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { findLast } from "../../../shared/array" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +const { mockSaveTaskMessages, mockSaveApiMessages } = vi.hoisted(() => ({ + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), +})) + +// vscode is globally aliased to __mocks__/vscode.js by vitest.config.ts. +vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined) })) +vi.mock("execa", () => ({ execa: vi.fn() })) +vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) })) + +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + } + }), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi + .fn() + .mockImplementation((text) => + Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }), + ), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../mentions/processUserContentMentions", () => ({ + processUserContentMentions: vi.fn().mockImplementation(async ({ userContent }: { userContent: unknown[] }) => ({ + content: userContent, + mode: undefined, + })), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((base: string, id: string) => Promise.resolve(`${base}/tasks/${id}`)), + getSettingsDirectoryPath: vi.fn().mockImplementation((base: string) => Promise.resolve(`${base}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +vi.mock("../../../i18n", () => ({ + t: (key: string) => key, +})) + +function makeMockProvider() { + return { + log: vi.fn(), + taskHistoryStore: { get: () => undefined }, + updateTaskHistory: vi.fn().mockResolvedValue([]), + getState: vi.fn().mockResolvedValue({}), + getSkillsManager: vi.fn().mockReturnValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + context: { + globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage-abort-race") }, + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + workspaceState: { + get: vi.fn().mockImplementation(() => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + extensionUri: { fsPath: "/mock/extension" }, + extension: { packageJSON: { version: "1.0.0" } }, + }, + } +} + +describe("Task abort-reason race", () => { + let mockApiConfig: ProviderSettings + + beforeEach(() => { + vi.clearAllMocks() + mockSaveTaskMessages.mockResolvedValue(undefined) + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + mockApiConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + }) + + it("keeps user_cancelled when a cancel lands during abortStream cleanup", async () => { + const mockProvider = makeMockProvider() + + // Double cast: plain object satisfies only the methods called by this code path. + const task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + const abortTaskSpy = vi.spyOn(task, "abortTask") + + // eslint-disable-next-line require-yield -- intentional error-only async source + task["attemptApiRequest"] = async function* () { + throw new Error("mid-stream API failure") + } + + // abortStream calls updateApiReqMsg("streaming_failed") synchronously before saving, + // so the api_req_started row has cancelReason="streaming_failed" when we are inside it. + mockSaveTaskMessages.mockImplementation(async () => { + const apiReqMsg = findLast(task.clineMessages, (m) => m.say === "api_req_started") + const parsed = apiReqMsg?.text ? (JSON.parse(apiReqMsg.text) as { cancelReason?: string }) : {} + if (parsed.cancelReason === "streaming_failed" && !task.didFinishAbortingStream) { + // cancelTask sets abortReason before abort=true. + task["abortReason"] = "user_cancelled" + task["abort"] = true + } + }) + + // The outer catch in recursivelyMakeClineRequests swallows the abort throw and returns true. + const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }]) + expect(result).toBe(true) + expect(task.didFinishAbortingStream).toBe(true) + expect(abortTaskSpy).toHaveBeenCalledOnce() + expect(task.abortReason).toBe("user_cancelled") + }) + + it("keeps pre-existing abortReason when cancel lands during retry backoff", async () => { + const mockProvider = makeMockProvider() + // autoApprovalEnabled causes the else branch to call backoffAndAnnounce. + mockProvider.getState = vi.fn().mockResolvedValue({ autoApprovalEnabled: true }) + + // Double cast: plain object satisfies only the methods called by this code path. + const task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + + // eslint-disable-next-line require-yield -- intentional error-only async source + task["attemptApiRequest"] = async function* () { + throw new Error("mid-stream failure") + } + + // Simulate cancel landing during backoff: cancelTask sets abortReason before abort=true. + // Use a distinct seed value so ??= (keeps it) is distinguishable from = (overwrites). + task["backoffAndAnnounce"] = async () => { + task["abortReason"] = "streaming_failed" + task["abort"] = true + } + + const abortTaskSpy = vi.spyOn(task, "abortTask") + // break after backoff exits the while loop; the outer try returns false. + const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }]) + expect(result).toBe(false) + expect(abortTaskSpy).toHaveBeenCalledOnce() + expect(task.abortReason).toBe("streaming_failed") + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2f8c7f8adc..185cfbb427 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -406,35 +406,7 @@ export class ClineProvider } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } - const onTaskAborted = async () => { - this.emit(RooCodeEventName.TaskAborted, instance.taskId) - - try { - // Only rehydrate on genuine streaming failures. - // User-initiated cancels are handled by cancelTask(). - if (instance.abortReason === "streaming_failed") { - // Defensive safeguard: if another path already replaced this instance, skip - const current = this.getCurrentTask() - if (current && current.instanceId !== instance.instanceId) { - this.log( - `[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`, - ) - return - } - - const { historyItem } = await this.getTaskWithId(instance.taskId) - const rootTask = instance.rootTask - const parentTask = instance.parentTask - await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) - } - } catch (error) { - this.log( - `[onTaskAborted] Failed to rehydrate after streaming failure: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } + const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId) const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 61254a67d6..753332dd5b 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -961,6 +961,25 @@ describe("ClineProvider Task History Synchronization", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) }) + it("onTaskAborted does not call createTaskWithHistoryItem", async () => { + // Store the item so getTaskWithId succeeds: without it, the old branch's catch + // swallows the error and the test passes even if the branch comes back. + const existing = createHistoryItem({ id: "task-abort-1", task: "T" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + const createSpy = vi.spyOn(provider, "createTaskWithHistoryItem") + const abortedListener = vi.fn() + provider.on(RooCodeEventName.TaskAborted, abortedListener) + + const fakeTask = { ...makeFakeTask("task-abort-1"), abortReason: "streaming_failed" } + // Double cast: taskCreationCallback reads only on/taskId/abortReason from the fake. + provider["taskCreationCallback"](fakeTask as unknown as Task) + await fakeTask.emit(RooCodeEventName.TaskAborted) + + expect(abortedListener).toHaveBeenCalledExactlyOnceWith("task-abort-1") + expect(createSpy).not.toHaveBeenCalled() + }) + it("emits delegated completion through the provider after the child is disposed", () => { const listener = vi.fn() provider.on(RooCodeEventName.TaskCompleted, listener) From 2225856937637fa3b7c8f7d0f7f0e3acca82540e Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:09:44 +0000 Subject: [PATCH 07/35] fix: reset didFinishAbortingStream for each API request (#1801) (#1812) * fix: reset didFinishAbortingStream for each request * test: wait for cancelTask to enter its wait before asserting pending --- src/core/task/Task.ts | 1 + src/core/task/__tests__/Task.spec.ts | 93 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 47e9e2ec37..d5313f68cf 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3236,6 +3236,7 @@ export class Task extends EventEmitter implements TaskLike { this.didRejectTool = false this.didAlreadyUseTool = false this.assistantMessageSavedToHistory = false + this.didFinishAbortingStream = false this.resetAssistantMessagePersistence() // Reset tool failure flag for each new assistant turn - this ensures that tool failures // only prevent attempt_completion within the same assistant message, not across turns diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 72d0641efd..eae85cf9f2 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -86,6 +86,7 @@ vi.mock("delay", () => ({ })) import delay from "delay" +import pWaitFor from "p-wait-for" vi.mock("uuid", async (importOriginal) => { const actual = await importOriginal() @@ -713,6 +714,98 @@ describe("Cline", () => { ]) }) + it("clears didFinishAbortingStream on retry so cancelTask waits for the new stream (#1801)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "abort flag reset test", + startTask: false, + }) + + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) + vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) + + const failingStream = async function* (): AsyncGenerator { + yield { type: "text", text: "partial" } + throw new Error("simulated mid-stream failure") + } + + let releaseRetryStream: (() => void) | undefined + let markRetryStreamPaused: (() => void) | undefined + const retryStreamRelease = new Promise((resolve) => { + releaseRetryStream = resolve + }) + const retryStreamPaused = new Promise((resolve) => { + markRetryStreamPaused = resolve + }) + const pausedRetryStream = async function* (): AsyncGenerator { + yield { type: "text", text: "retry" } + markRetryStreamPaused?.() + await retryStreamRelease + } + + const attemptApiRequestSpy = vi + .spyOn(task, "attemptApiRequest") + .mockImplementationOnce(() => failingStream()) + .mockImplementationOnce(() => pausedRetryStream()) + .mockImplementation(() => { + throw new Error("stop after retry response") + }) + + const { default: realPWaitFor } = await vi.importActual("p-wait-for") + vi.mocked(pWaitFor).mockImplementation(realPWaitFor) + let request: Promise | undefined + let cancel: Promise | undefined + try { + await mockProvider.addClineToStack(task) + request = task.recursivelyMakeClineRequests([{ type: "text", text: "abort flag reset test" }]) + await Promise.race([ + retryStreamPaused, + request.then(() => { + throw new Error("request settled before the retry stream paused") + }), + ]) + + const firstApiReq = task.clineMessages.find((message) => message.say === "api_req_started") + expect(JSON.parse(firstApiReq?.text ?? "{}")).toMatchObject({ cancelReason: "streaming_failed" }) + expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) + + let markCancelWaiting: (() => void) | undefined + const cancelWaiting = new Promise((resolve) => { + markCancelWaiting = resolve + }) + vi.mocked(pWaitFor).mockImplementation((condition, options) => + realPWaitFor(async () => { + const done = await condition() + if (!done) markCancelWaiting?.() + return done + }, options), + ) + + vi.spyOn(mockProvider, "createTaskWithHistoryItem").mockImplementation((historyItem) => + ClineProvider.prototype.createTaskWithHistoryItem.call(mockProvider, historyItem, { + startTask: false, + }), + ) + + let cancelSettled = false + cancel = mockProvider.cancelTask().then(() => { + cancelSettled = true + }) + await Promise.race([cancelWaiting, cancel]) + expect(cancelSettled, "cancelTask must wait while the retry stream is live").toBe(false) + + releaseRetryStream?.() + await cancel + await expect(request).resolves.toBe(true) + } finally { + releaseRetryStream?.() + await Promise.allSettled([request, cancel]) + vi.mocked(pWaitFor).mockImplementation(async () => {}) + } + }) + it("finalizes MCP tool call using the request-scoped parser state", async () => { const task = new Task({ provider: mockProvider, From d351a155e38abb34040b0a867fb29c8270c634b0 Mon Sep 17 00:00:00 2001 From: Alexei Gubin <36731953+WebMad@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:47:10 +0000 Subject: [PATCH 08/35] refactor(code-index): introduce workspace scope behind registry (#1766) * refactor(code-index): introduce workspace scope behind registry Refs Zoo-Code-Org/Zoo-Code#1594 * test(code-index): cover registry retry after construction failure --- .../code-index-manager-registry.spec.ts | 54 ++++++++++ .../code-index-workspace-scope.spec.ts | 100 ++++++++++++++++++ .../code-index/code-index-manager-registry.ts | 33 +++--- .../code-index/code-index-workspace-scope.ts | 42 ++++++++ 4 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 src/services/code-index/__tests__/code-index-workspace-scope.spec.ts create mode 100644 src/services/code-index/code-index-workspace-scope.ts diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts index 9879ff8ea9..28ecb5d6f0 100644 --- a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts +++ b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" import { CodeIndexManager } from "../manager" import { CodeIndexManagerRegistry } from "../code-index-manager-registry" +import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" vi.mock("vscode", () => ({ workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, @@ -107,6 +108,22 @@ describe("CodeIndexManagerRegistry", () => { expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([a, b]) }) + it("does not cache a scope when manager construction fails and permits retry for the same path", () => { + const error = new Error("construction failed") + vi.mocked(CodeIndexManager).mockImplementationOnce(function () { + throw error + }) + + expect(() => CodeIndexManagerRegistry.getOrCreate(context, "/first")).toThrow(error) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + + const manager = CodeIndexManagerRegistry.getOrCreate(context, "/first") + expect(manager).toBe(vi.mocked(CodeIndexManager).mock.results[1].value) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([manager]) + expect(CodeIndexManagerRegistry.getOrCreate(context, "/first")).toBe(manager) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + }) + it("returns a snapshot that cannot mutate the cache", () => { expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) const manager = CodeIndexManagerRegistry.getOrCreate(context) @@ -114,11 +131,48 @@ describe("CodeIndexManagerRegistry", () => { expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([manager]) }) + it("logs each disposal failure, continues cleanup and permits recreation", () => { + const logError = vi.spyOn(console, "error").mockImplementation(() => {}) + const first = CodeIndexManagerRegistry.getOrCreate(context, "/first")! + const second = CodeIndexManagerRegistry.getOrCreate(context, "/second")! + const third = CodeIndexManagerRegistry.getOrCreate(context, "/third")! + const error = new Error("cleanup failed") + vi.mocked(first.dispose).mockImplementationOnce(() => { + throw error + }) + vi.mocked(second.dispose).mockImplementationOnce(() => { + throw "second cleanup failed" + }) + + expect(() => CodeIndexManagerRegistry.disposeAll()).not.toThrow() + for (const manager of [first, second, third]) { + expect(manager.dispose).toHaveBeenCalledExactlyOnceWith() + } + expect(logError).toHaveBeenCalledTimes(2) + expect(logError).toHaveBeenNthCalledWith( + 1, + "[CodeIndexManagerRegistry] Failed to dispose workspace scope for /first:", + error, + ) + expect(logError).toHaveBeenNthCalledWith( + 2, + "[CodeIndexManagerRegistry] Failed to dispose workspace scope for /second:", + "second cleanup failed", + ) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + CodeIndexManagerRegistry.disposeAll() + expect(logError).toHaveBeenCalledTimes(2) + expect(first.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.getOrCreate(context, "/first")).not.toBe(first) + }) + it("disposes every manager, supports repeated cleanup and recreates instances", () => { + const disposeScope = vi.spyOn(CodeIndexWorkspaceScope.prototype, "dispose") const a = CodeIndexManagerRegistry.getOrCreate(context, "/first")! const b = CodeIndexManagerRegistry.getOrCreate(context, "/second")! CodeIndexManagerRegistry.disposeAll() CodeIndexManagerRegistry.disposeAll() + expect(disposeScope).toHaveBeenCalledTimes(2) expect(a.dispose).toHaveBeenCalledTimes(1) expect(b.dispose).toHaveBeenCalledTimes(1) expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) diff --git a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts new file mode 100644 index 0000000000..2e20f07799 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts @@ -0,0 +1,100 @@ +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { initialize: vi.fn(), dispose: vi.fn() } + }), +})) + +describe("CodeIndexWorkspaceScope", () => { + beforeEach(() => vi.clearAllMocks()) + + it("guards generic values and preserves defined falsy values", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + expect(() => scope["ensureInitialized"](42)).toThrow("Code index workspace scope is not initialized") + scope.init() + expect(() => scope["ensureInitialized"](undefined)).toThrow("Code index workspace scope is not initialized") + expect(scope["ensureInitialized"](false)).toBe(false) + expect(scope["ensureInitialized"](0)).toBe(0) + expect(scope["ensureInitialized"]("")).toBe("") + }) + + it("creates its manager only on init without starting configuration initialization", () => { + const context = makeExtensionContext() + const uri = makeUri("/workspace") + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, context) + + expect(CodeIndexManager).not.toHaveBeenCalled() + expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") + expect(scope.init()).toBeUndefined() + const manager = scope.codeIndexManager + + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith(uri.fsPath, uri, context) + expect(scope.codeIndexManager).toBe(manager) + expect(() => scope.init()).toThrow("Code index workspace scope is already initialized") + expect(scope.codeIndexManager).toBe(manager) + expect(CodeIndexManager).toHaveBeenCalledTimes(1) + expect(manager.initialize).not.toHaveBeenCalled() + }) + + it("synchronously disposes its manager", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + scope.init() + const manager = scope.codeIndexManager + + expect(scope.dispose()).toBeUndefined() + expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") + scope.dispose() + expect(manager.dispose).toHaveBeenCalledExactlyOnceWith() + }) + + it("can dispose before dependency creation without constructing a manager", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + scope.dispose() + expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("creates a fresh manager when initialized after disposal", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + scope.init() + const previous = scope.codeIndexManager + scope.dispose() + scope.init() + const current = scope.codeIndexManager + expect(current).not.toBe(previous) + expect(scope.codeIndexManager).toBe(current) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + expect(current.dispose).not.toHaveBeenCalled() + }) + + it("clears its reference even when manager disposal throws", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + scope.init() + const manager = scope.codeIndexManager + const error = new Error("disposal failed") + vi.mocked(manager.dispose).mockImplementationOnce(() => { + throw error + }) + expect(() => scope.dispose()).toThrow(error) + expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") + scope.dispose() + expect(manager.dispose).toHaveBeenCalledTimes(1) + }) + + it("remains uninitialized when construction fails and permits retry", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + const error = new Error("construction failed") + vi.mocked(CodeIndexManager).mockImplementationOnce(function () { + throw error + }) + + expect(() => scope.init()).toThrow(error) + expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") + expect(scope.init()).toBeUndefined() + expect(scope.codeIndexManager).toBe(vi.mocked(CodeIndexManager).mock.results[1].value) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/services/code-index/code-index-manager-registry.ts b/src/services/code-index/code-index-manager-registry.ts index 635ec62647..057745e1fd 100644 --- a/src/services/code-index/code-index-manager-registry.ts +++ b/src/services/code-index/code-index-manager-registry.ts @@ -1,9 +1,10 @@ import * as vscode from "vscode" -import { CodeIndexManager } from "./manager" +import type { CodeIndexManager } from "./manager" +import { CodeIndexWorkspaceScope } from "./code-index-workspace-scope" -/** Resolves workspaces and owns their cached CodeIndexManager instances. */ +/** Owns workspace scopes while preserving the manager-facing API. */ export class CodeIndexManagerRegistry { - private static instances = new Map() + private static codeIndexWorkspaceScopes = new Map() public static getOrCreate(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { const folder = this.resolveWorkspaceFolder(workspacePath) @@ -12,27 +13,35 @@ export class CodeIndexManagerRegistry { return undefined } - const existing = this.instances.get(resolvedPath) + const existing = this.codeIndexWorkspaceScopes.get(resolvedPath) if (existing) { - return existing + return existing.codeIndexManager } // Preserve real workspace URIs, including remote schemes and authorities. const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) - const manager = new CodeIndexManager(resolvedPath, folderUri, context) - this.instances.set(resolvedPath, manager) - return manager + const codeIndexWorkspaceScope = new CodeIndexWorkspaceScope(resolvedPath, folderUri, context) + codeIndexWorkspaceScope.init() + this.codeIndexWorkspaceScopes.set(resolvedPath, codeIndexWorkspaceScope) + return codeIndexWorkspaceScope.codeIndexManager } public static getAllInstances(): CodeIndexManager[] { - return Array.from(this.instances.values()) + return Array.from(this.codeIndexWorkspaceScopes.values(), (scope) => scope.codeIndexManager) } public static disposeAll(): void { - for (const instance of this.instances.values()) { - instance.dispose() + for (const [workspacePath, codeIndexWorkspaceScope] of this.codeIndexWorkspaceScopes) { + try { + codeIndexWorkspaceScope.dispose() + } catch (error) { + console.error( + `[CodeIndexManagerRegistry] Failed to dispose workspace scope for ${workspacePath}:`, + error, + ) + } } - this.instances.clear() + this.codeIndexWorkspaceScopes.clear() } private static resolveWorkspaceFolder(workspacePath?: string): vscode.WorkspaceFolder | undefined { diff --git a/src/services/code-index/code-index-workspace-scope.ts b/src/services/code-index/code-index-workspace-scope.ts new file mode 100644 index 0000000000..683a783335 --- /dev/null +++ b/src/services/code-index/code-index-workspace-scope.ts @@ -0,0 +1,42 @@ +import type * as vscode from "vscode" + +import { CodeIndexManager } from "./manager" + +/** Owns code-index services for one workspace; initialization remains with existing callers. */ +export class CodeIndexWorkspaceScope implements vscode.Disposable { + private _codeIndexManager?: CodeIndexManager + private _isInitialized = false + + public constructor( + private readonly workspacePath: string, + private readonly folderUri: vscode.Uri, + private readonly context: vscode.ExtensionContext, + ) {} + + public get codeIndexManager(): CodeIndexManager { + return this.ensureInitialized(this._codeIndexManager) + } + + private ensureInitialized(value: T | undefined): T { + if (!this._isInitialized || value === undefined) { + throw new Error("Code index workspace scope is not initialized") + } + return value + } + + /** Creates the manager without loading configuration or starting indexing. */ + public init(): void { + if (this._isInitialized) { + throw new Error("Code index workspace scope is already initialized") + } + this._codeIndexManager = new CodeIndexManager(this.workspacePath, this.folderUri, this.context) + this._isInitialized = true + } + + public dispose(): void { + const manager = this._codeIndexManager + this._codeIndexManager = undefined + this._isInitialized = false + manager?.dispose() + } +} From 8bec7c138f9acfbe2d93a353b0873541105b9abe Mon Sep 17 00:00:00 2001 From: Alexei Gubin <36731953+WebMad@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:23:59 +0000 Subject: [PATCH 09/35] refactor(code-index): extract scan execution without behavior changes (#1834) --- .../code-index-scan-executor.spec.ts | 149 ++++++++++++++++++ .../code-index/__tests__/orchestrator.spec.ts | 66 ++++++++ .../code-index/code-index-scan-executor.ts | 101 ++++++++++++ src/services/code-index/orchestrator.ts | 141 ++--------------- 4 files changed, 325 insertions(+), 132 deletions(-) create mode 100644 src/services/code-index/__tests__/code-index-scan-executor.spec.ts create mode 100644 src/services/code-index/code-index-scan-executor.ts diff --git a/src/services/code-index/__tests__/code-index-scan-executor.spec.ts b/src/services/code-index/__tests__/code-index-scan-executor.spec.ts new file mode 100644 index 0000000000..33b71584ff --- /dev/null +++ b/src/services/code-index/__tests__/code-index-scan-executor.spec.ts @@ -0,0 +1,149 @@ +import { CodeIndexScanExecutor } from "../code-index-scan-executor" +import type { IDirectoryScanner } from "../interfaces" + +vi.mock("../../../i18n", () => ({ t: (key: string) => key })) + +function setup() { + const scanner = { scanDirectory: vi.fn() } + const vectorStore = { markIndexingIncomplete: vi.fn().mockResolvedValue(undefined) } + const stateManager = { setSystemState: vi.fn(), reportBlockIndexingProgress: vi.fn() } + return { + scanner, + vectorStore, + stateManager, + executor: new CodeIndexScanExecutor("/workspace", scanner, vectorStore, stateManager), + } +} + +describe("CodeIndexScanExecutor", () => { + it.each(["runFullScan", "runIncrementalScan"] as const)( + "%s skips failure validation when cancellation occurs during the scan", + async (method) => { + const { executor, scanner } = setup() + const controller = new AbortController() + scanner.scanDirectory.mockImplementation(async (_path, onError, _onIndexed, onParsed) => { + onParsed?.(3) + onError?.(new Error("batch failure")) + controller.abort() + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 3 } + }) + await expect(executor[method](controller.signal)).resolves.toBe(false) + }, + ) + + it("completes an incremental scan with no changed files", async () => { + const { executor, scanner } = setup() + scanner.scanDirectory.mockResolvedValue({ stats: { processed: 0, skipped: 2 }, totalBlockCount: 0 }) + await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true) + }) + + it.each(["runFullScan", "runIncrementalScan"] as const)( + "%s preserves progress and signal wiring", + async (method) => { + const { executor, scanner, vectorStore, stateManager } = setup() + const signal = new AbortController().signal + scanner.scanDirectory.mockImplementation(async (path, _onError, onIndexed, onParsed, receivedSignal) => { + expect(path).toBe("/workspace") + expect(receivedSignal).toBe(signal) + expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledOnce() + onParsed?.(2) + onIndexed?.(1) + onParsed?.(1) + onIndexed?.(2) + return { stats: { processed: 2, skipped: 0 }, totalBlockCount: 3 } + }) + await expect(executor[method](signal)).resolves.toBe(true) + expect(stateManager.reportBlockIndexingProgress.mock.calls).toEqual([ + [0, 2], + [1, 2], + [1, 3], + [3, 3], + ]) + expect(stateManager.setSystemState).toHaveBeenCalledExactlyOnceWith( + "Indexing", + method === "runFullScan" + ? "Services ready. Starting workspace scan..." + : "Checking for new or modified files...", + ) + }, + ) + + it.each(["runFullScan", "runIncrementalScan"] as const)("%s rejects missing results", async (method) => { + const { executor } = setup() + // An unconfigured mock simulates a scanner violating its return contract. + await expect(executor[method](new AbortController().signal)).rejects.toThrow( + method === "runFullScan" + ? "Scan failed, is scanner initialized?" + : "Incremental scan failed, is scanner initialized?", + ) + }) + + it.each(["runFullScan", "runIncrementalScan"] as const)( + "%s returns cancellation before validating results", + async (method) => { + const { executor, scanner } = setup() + const controller = new AbortController() + // Abort before the unconfigured scanner returns a missing result. + controller.abort() + await expect(executor[method](controller.signal)).resolves.toBe(false) + expect(scanner.scanDirectory).toHaveBeenCalledOnce() + }, + ) + + it.each(["runFullScan", "runIncrementalScan"] as const)( + "%s propagates scanner and marker failures unchanged", + async (method) => { + const { executor, scanner, vectorStore } = setup() + const signal = new AbortController().signal + const failure = new Error("scan failed") + scanner.scanDirectory.mockRejectedValueOnce(failure) + await expect(executor[method](signal)).rejects.toBe(failure) + const abort = new DOMException("stopped", "AbortError") + scanner.scanDirectory.mockRejectedValueOnce(abort) + await expect(executor[method](signal)).rejects.toBe(abort) + vectorStore.markIndexingIncomplete.mockRejectedValueOnce(failure) + await expect(executor[method](signal)).rejects.toBe(failure) + expect(scanner.scanDirectory).toHaveBeenCalledTimes(2) + }, + ) + + it.each([ + { found: 0, indexed: 0, error: false, message: undefined }, + { found: 3, indexed: 0, error: false, message: "embeddings:orchestrator.indexingFailedNoBlocks" }, + { found: 3, indexed: 0, error: true, message: "Indexing failed: batch failure" }, + { found: 0, indexed: 0, error: true, message: "Indexing failed completely: batch failure" }, + { found: 10, indexed: 9, error: true, message: undefined }, + { + found: 10, + indexed: 8, + error: true, + message: "Indexing partially failed: Only 8 of 10 blocks were indexed. batch failure", + }, + { found: 10, indexed: 8, error: false, message: undefined }, + ])( + "preserves full-scan policy for $indexed/$found blocks, error=$error", + async ({ found, indexed, error, message }) => { + const { executor, scanner } = setup() + scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => { + onParsed?.(found) + onIndexed?.(indexed) + if (error) onError?.(new Error("batch failure")) + return { stats: { processed: 1, skipped: 0 }, totalBlockCount: found } + }) + const result = executor.runFullScan(new AbortController().signal) + if (message) await expect(result).rejects.toThrow(message) + else await expect(result).resolves.toBe(true) + }, + ) + + it.each([0, 2])("preserves incremental batch-error tolerance with %s indexed blocks", async (indexed) => { + const { executor, scanner } = setup() + scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => { + onParsed?.(3) + onIndexed?.(indexed) + onError?.(new Error("batch failure")) + return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 } + }) + await expect(executor.runIncrementalScan(new AbortController().signal)).resolves.toBe(true) + }) +}) diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index 86b0f94808..bad3d69be3 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -107,6 +107,72 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { } }) + it.each([false, true])( + "keeps watcher startup and completion after scanning (incremental: %s)", + async (incremental) => { + const events: string[] = [] + vectorStore.initialize.mockResolvedValue(false) + vectorStore.hasIndexedData.mockResolvedValue(incremental) + vectorStore.markIndexingIncomplete.mockImplementation(async () => { + events.push("incomplete") + }) + scanner.scanDirectory.mockImplementation(async () => { + events.push("scan") + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + }) + fileWatcher.initialize.mockImplementation(async () => { + events.push("watcher") + }) + vectorStore.markIndexingComplete.mockImplementation(async () => { + events.push("complete") + }) + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + await orchestrator.startIndexing() + expect(events).toEqual(["incomplete", "scan", "watcher", "complete"]) + expect(orchestrator.state).toBe("Indexed") + expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + }, + ) + + it.each([false, true])( + "keeps returned cancellation cleanup in the owner (incremental: %s)", + async (incremental) => { + vectorStore.initialize.mockResolvedValue(false) + vectorStore.hasIndexedData.mockResolvedValue(incremental) + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + scanner.scanDirectory.mockImplementation(async () => { + orchestrator.stopIndexing() + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + }) + // Preserve the original normal-return path: a failed flush is retried by the catch handler. + cacheManager.flush.mockRejectedValueOnce(new Error("flush failed")) + await orchestrator.startIndexing() + expect(cacheManager.flush).toHaveBeenCalledTimes(2) + expect(orchestrator.state).toBe("Standby") + expect(fileWatcher.initialize).not.toHaveBeenCalled() + expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled() + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() + }, + ) + it("should not call clearCollection() or clear cache when initialize() fails (indexing not started)", async () => { // Arrange: fail at initialize() vectorStore.initialize.mockRejectedValue(new Error("Qdrant unreachable")) diff --git a/src/services/code-index/code-index-scan-executor.ts b/src/services/code-index/code-index-scan-executor.ts new file mode 100644 index 0000000000..2ff55454d9 --- /dev/null +++ b/src/services/code-index/code-index-scan-executor.ts @@ -0,0 +1,101 @@ +import type { IDirectoryScanner, IVectorStore } from "./interfaces" +import type { CodeIndexStateManager } from "./state-manager" +import { t } from "../../i18n" + +/** Executes scans; cancellation cleanup, watcher startup and completion remain with the orchestrator. */ +export class CodeIndexScanExecutor { + constructor( + private readonly workspacePath: string, + private readonly scanner: IDirectoryScanner, + private readonly vectorStore: Pick, + private readonly stateManager: Pick, + ) {} + + /** Returns false when the scanner returns after cancellation, preserving the owner's normal-return path. */ + async runIncrementalScan(signal: AbortSignal): Promise { + console.log( + "[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...", + ) + this.stateManager.setSystemState("Indexing", "Checking for new or modified files...") + const summary = await this.scanWorkspace(signal, "incremental") + if (!summary) return false + + // Preserve the existing incremental policy: reported batch errors do not prevent completion. + if (summary.found > 0) { + console.log( + `[CodeIndexOrchestrator] Incremental scan completed: ${summary.indexed} blocks indexed from new/changed files`, + ) + } else { + console.log("[CodeIndexOrchestrator] No new or changed files found") + } + return true + } + + /** Returns false on returned cancellation; scanner rejections propagate unchanged. */ + async runFullScan(signal: AbortSignal): Promise { + this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...") + const summary = await this.scanWorkspace(signal, "full") + if (!summary) return false + this.validateFullScan(summary.indexed, summary.found, summary.batchErrors) + return true + } + + private async scanWorkspace( + signal: AbortSignal, + kind: "full" | "incremental", + ): Promise<{ indexed: number; found: number; batchErrors: Error[] } | undefined> { + await this.vectorStore.markIndexingIncomplete() + let indexed = 0 + let found = 0 + const batchErrors: Error[] = [] + const result = await this.scanner.scanDirectory( + this.workspacePath, + (batchError: Error) => { + console.error( + `[CodeIndexOrchestrator] Error during ${kind === "full" ? "initial" : "incremental"} scan batch: ${batchError.message}`, + batchError, + ) + batchErrors.push(batchError) + }, + (count: number) => { + indexed += count + this.stateManager.reportBlockIndexingProgress(indexed, found) + }, + (count: number) => { + found += count + this.stateManager.reportBlockIndexingProgress(indexed, found) + }, + signal, + ) + + // Cancellation precedes missing-result checks and validation, just as in the orchestrator. + if (signal.aborted) return undefined + if (!result) { + throw new Error( + kind === "full" + ? "Scan failed, is scanner initialized?" + : "Incremental scan failed, is scanner initialized?", + ) + } + return { indexed, found, batchErrors } + } + + private validateFullScan(indexed: number, found: number, batchErrors: Error[]): void { + const firstError = batchErrors[0] + if (indexed === 0 && found > 0) { + throw new Error( + firstError + ? `Indexing failed: ${firstError.message}` + : t("embeddings:orchestrator.indexingFailedNoBlocks"), + ) + } + if (firstError && (found - indexed) / found > 0.1) { + throw new Error( + `Indexing partially failed: Only ${indexed} of ${found} blocks were indexed. ${firstError.message}`, + ) + } + if (firstError && indexed === 0) { + throw new Error(`Indexing failed completely: ${firstError.message}`) + } + } +} diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 1efe647be9..c055fff370 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -5,6 +5,7 @@ import { CodeIndexStateManager, IndexingState } from "./state-manager" import { IFileWatcher, IVectorStore, BatchProcessingSummary } from "./interfaces" import { DirectoryScanner } from "./processors" import { CacheManager } from "./cache-manager" +import { CodeIndexScanExecutor } from "./code-index-scan-executor" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" import { t } from "../../i18n" @@ -16,16 +17,19 @@ export class CodeIndexOrchestrator { private _fileWatcherSubscriptions: vscode.Disposable[] = [] private _isProcessing: boolean = false private _abortController: AbortController | null = null + private readonly scanExecutor: CodeIndexScanExecutor constructor( private readonly configManager: CodeIndexConfigManager, private readonly stateManager: CodeIndexStateManager, - private readonly workspacePath: string, + workspacePath: string, private readonly cacheManager: CacheManager, private readonly vectorStore: IVectorStore, - private readonly scanner: DirectoryScanner, + scanner: DirectoryScanner, private readonly fileWatcher: IFileWatcher, - ) {} + ) { + this.scanExecutor = new CodeIndexScanExecutor(workspacePath, scanner, vectorStore, stateManager) + } /** * Starts the file watcher if not already running. @@ -145,65 +149,13 @@ export class CodeIndexOrchestrator { const hasExistingData = await this.vectorStore.hasIndexedData() if (hasExistingData && !collectionCreated) { - // Collection exists with data - run incremental scan to catch any new/changed files - // This handles files added while workspace was closed or Qdrant was inactive - console.log( - "[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...", - ) - this.stateManager.setSystemState("Indexing", "Checking for new or modified files...") - - // Mark as incomplete at the start of incremental scan - await this.vectorStore.markIndexingIncomplete() - - let cumulativeBlocksIndexed = 0 - let cumulativeBlocksFoundSoFar = 0 - const batchErrors: Error[] = [] - - const handleFileParsed = (fileBlockCount: number) => { - cumulativeBlocksFoundSoFar += fileBlockCount - this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar) - } - - const handleBlocksIndexed = (indexedCount: number) => { - cumulativeBlocksIndexed += indexedCount - this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar) - } - - // Run incremental scan - scanner will skip unchanged files using cache - const result = await this.scanner.scanDirectory( - this.workspacePath, - (batchError: Error) => { - console.error( - `[CodeIndexOrchestrator] Error during incremental scan batch: ${batchError.message}`, - batchError, - ) - batchErrors.push(batchError) - }, - handleBlocksIndexed, - handleFileParsed, - signal, - ) - - if (signal.aborted) { + if (!(await this.scanExecutor.runIncrementalScan(signal))) { await this.cacheManager.flush() this.stopWatcher() this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) return } - if (!result) { - throw new Error("Incremental scan failed, is scanner initialized?") - } - - // If new files were found and indexed, log the results - if (cumulativeBlocksFoundSoFar > 0) { - console.log( - `[CodeIndexOrchestrator] Incremental scan completed: ${cumulativeBlocksIndexed} blocks indexed from new/changed files`, - ) - } else { - console.log("[CodeIndexOrchestrator] No new or changed files found") - } - await this._startWatcher() // Mark indexing as complete after successful incremental scan @@ -211,88 +163,13 @@ export class CodeIndexOrchestrator { this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) } else { - // No existing data or collection was just created - do a full scan - this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...") - - // Mark as incomplete at the start of full scan - await this.vectorStore.markIndexingIncomplete() - - let cumulativeBlocksIndexed = 0 - let cumulativeBlocksFoundSoFar = 0 - const batchErrors: Error[] = [] - - const handleFileParsed = (fileBlockCount: number) => { - cumulativeBlocksFoundSoFar += fileBlockCount - this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar) - } - - const handleBlocksIndexed = (indexedCount: number) => { - cumulativeBlocksIndexed += indexedCount - this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar) - } - - const result = await this.scanner.scanDirectory( - this.workspacePath, - (batchError: Error) => { - console.error( - `[CodeIndexOrchestrator] Error during initial scan batch: ${batchError.message}`, - batchError, - ) - batchErrors.push(batchError) - }, - handleBlocksIndexed, - handleFileParsed, - signal, - ) - - if (signal.aborted) { + if (!(await this.scanExecutor.runFullScan(signal))) { await this.cacheManager.flush() this.stopWatcher() this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) return } - if (!result) { - throw new Error("Scan failed, is scanner initialized?") - } - - const { stats } = result - - // Check if any blocks were actually indexed successfully - // If no blocks were indexed but blocks were found, it means all batches failed - if (cumulativeBlocksIndexed === 0 && cumulativeBlocksFoundSoFar > 0) { - if (batchErrors.length > 0) { - // Use the first batch error as it's likely representative of the main issue - const firstError = batchErrors[0] - throw new Error(`Indexing failed: ${firstError.message}`) - } else { - throw new Error(t("embeddings:orchestrator.indexingFailedNoBlocks")) - } - } - - // Check for partial failures - if a significant portion of blocks failed - const failureRate = (cumulativeBlocksFoundSoFar - cumulativeBlocksIndexed) / cumulativeBlocksFoundSoFar - if (batchErrors.length > 0 && failureRate > 0.1) { - // More than 10% of blocks failed to index - const firstError = batchErrors[0] - throw new Error( - `Indexing partially failed: Only ${cumulativeBlocksIndexed} of ${cumulativeBlocksFoundSoFar} blocks were indexed. ${firstError.message}`, - ) - } - - // CRITICAL: If there were ANY batch errors and NO blocks were successfully indexed, - // this is a complete failure regardless of the failure rate calculation - if (batchErrors.length > 0 && cumulativeBlocksIndexed === 0) { - const firstError = batchErrors[0] - throw new Error(`Indexing failed completely: ${firstError.message}`) - } - - // Final sanity check: If we found blocks but indexed none and somehow no errors were reported, - // this is still a failure - if (cumulativeBlocksFoundSoFar > 0 && cumulativeBlocksIndexed === 0) { - throw new Error(t("embeddings:orchestrator.indexingFailedCritical")) - } - await this._startWatcher() // Mark indexing as complete after successful full scan From 778ad3e18bd68c95281bc48e61c82122e61df6a4 Mon Sep 17 00:00:00 2001 From: Alexei Gubin <36731953+WebMad@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:56:01 +0000 Subject: [PATCH 10/35] refactor(code-index): scope state ownership and workspace status delivery (#1768) Centralize indexing status subscriptions in the extension scope and keep state ownership in workspace scopes. Honor the configured workspace root resolution when selecting the status source, with regression coverage for both selection strategies. --- src/__tests__/extension.spec.ts | 138 +++++++- .../CodebaseSearchTool.workspace.spec.ts | 3 + src/core/webview/ClineProvider.ts | 68 +--- .../webview/__tests__/ClineProvider.spec.ts | 9 + src/extension.ts | 10 + .../code-index-manager-registry.spec.ts | 22 +- .../__tests__/code-index-scope.spec.ts | 49 +++ .../code-index-status-manager.spec.ts | 311 ++++++++++++++++++ .../code-index-workspace-scope.spec.ts | 34 +- src/services/code-index/code-index-scope.ts | 29 ++ .../code-index/code-index-status-manager.ts | 79 +++++ .../code-index/code-index-workspace-scope.ts | 11 +- src/services/code-index/manager.ts | 11 +- 13 files changed, 698 insertions(+), 76 deletions(-) create mode 100644 src/services/code-index/__tests__/code-index-scope.spec.ts create mode 100644 src/services/code-index/__tests__/code-index-status-manager.spec.ts create mode 100644 src/services/code-index/code-index-scope.ts create mode 100644 src/services/code-index/code-index-status-manager.ts diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 56ccd52588..d0a8b274b8 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -12,7 +12,7 @@ vi.mock("vscode", () => ({ tabGroups: { onDidChangeTabs: vi.fn(), }, - onDidChangeActiveTextEditor: vi.fn(), + onDidChangeActiveTextEditor: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspace: { registerTextDocumentContentProvider: vi.fn(), @@ -205,6 +205,7 @@ vi.mock("../core/webview/ClineProvider", async () => { { // Static method used by extension.ts getVisibleInstance: vi.fn().mockReturnValue(mockInstance), + getAllInstances: vi.fn().mockReturnValue([]), sideBarId: "zoo-code.SidebarProvider", }, ), @@ -238,6 +239,141 @@ describe("extension.ts", () => { settingsUpdatedHandler = undefined }) + test("initializes the code index scope and registers it for extension cleanup", async () => { + vi.resetModules() + const { CodeIndexScope } = await import("../services/code-index/code-index-scope") + const init = vi.spyOn(CodeIndexScope.prototype, "init") + const dispose = vi.spyOn(CodeIndexScope.prototype, "dispose") + try { + const { activate } = await import("../extension") + await activate(mockContext) + + const scopes = mockContext.subscriptions.filter((entry) => entry instanceof CodeIndexScope) + expect(scopes).toHaveLength(1) + expect(init).toHaveBeenCalledExactlyOnceWith() + expect(init.mock.contexts[0]).toBe(scopes[0]) + expect(dispose).not.toHaveBeenCalled() + scopes[0].dispose() + expect(dispose).toHaveBeenCalledExactlyOnceWith() + } finally { + init.mockRestore() + dispose.mockRestore() + } + }) + + test("publishes indexing status to matching providers and logs delivery failures", async () => { + vi.resetModules() + const { CodeIndexScope } = await import("../services/code-index/code-index-scope") + const { ClineProvider } = await import("../core/webview/ClineProvider") + const log = vi.spyOn(console, "error").mockImplementation(() => {}) + const provider = ClineProvider.getVisibleInstance()! + Object.defineProperty(provider, "workspacePath", { configurable: true, value: "/workspace" }) + const getAll = vi.mocked(ClineProvider.getAllInstances) + const post = vi.mocked(provider.postMessageToWebview) + getAll.mockReturnValue([provider, provider]) + post.mockRejectedValueOnce(new Error("delivery failed")).mockResolvedValue(undefined) + try { + const { activate } = await import("../extension") + await activate(mockContext) + const scope = mockContext.subscriptions.find((entry) => entry instanceof CodeIndexScope)! + const status = { + systemStatus: "Standby" as const, + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: true, + } + scope["statusManager"]!["publishStatus"](status) + await Promise.resolve() + expect(post).toHaveBeenCalledTimes(2) + expect(post).toHaveBeenCalledWith({ type: "indexingStatusUpdate", values: status }) + expect(log).toHaveBeenCalledWith( + "[CodeIndexStatusManager] Failed to publish indexing status:", + expect.objectContaining({ message: "delivery failed" }), + ) + scope.dispose() + } finally { + log.mockRestore() + getAll.mockReturnValue([]) + post.mockReset() + Reflect.deleteProperty(provider, "workspacePath") + } + }) + + test.each([ + { workspacePath: "/other-workspace", receivesStatus: false }, + { workspacePath: "/workspace", receivesStatus: true }, + { workspacePath: undefined, receivesStatus: true }, + { workspacePath: "", receivesStatus: true }, + ])( + "routes status for provider workspace=$workspacePath with receivesStatus=$receivesStatus", + async ({ workspacePath, receivesStatus }) => { + vi.resetModules() + const { CodeIndexScope } = await import("../services/code-index/code-index-scope") + const { ClineProvider } = await import("../core/webview/ClineProvider") + const provider = ClineProvider.getVisibleInstance()! + Object.defineProperty(provider, "workspacePath", { configurable: true, value: workspacePath }) + const getAll = vi.mocked(ClineProvider.getAllInstances) + getAll.mockReturnValue([provider]) + try { + const { activate } = await import("../extension") + await activate(mockContext) + const scope = mockContext.subscriptions.find((entry) => entry instanceof CodeIndexScope)! + const status = { + systemStatus: "Indexing" as const, + message: "Processing confidential.ts", + processedItems: 1, + totalItems: 2, + currentItemUnit: "files", + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: true, + } + scope["statusManager"]!["publishStatus"](status) + await Promise.resolve() + if (receivesStatus) { + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "indexingStatusUpdate", + values: status, + }) + } else { + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + } + scope.dispose() + } finally { + getAll.mockReturnValue([]) + Reflect.deleteProperty(provider, "workspacePath") + } + }, + ) + + test.each([new Error("scope initialization failed"), "scope initialization failed"])( + "continues activation and logs code index scope initialization failure: %s", + async (error) => { + vi.resetModules() + const { CodeIndexScope } = await import("../services/code-index/code-index-scope") + const init = vi.spyOn(CodeIndexScope.prototype, "init").mockImplementationOnce(() => { + throw error + }) + try { + const { activate } = await import("../extension") + await expect(activate(mockContext)).resolves.toBeDefined() + + const vscode = await import("vscode") + const channel = vi.mocked(vscode.window.createOutputChannel).mock.results.at(-1)?.value + expect(channel?.appendLine).toHaveBeenCalledWith( + "[CodeIndexScope] Failed to initialize: scope initialization failed", + ) + expect(mockContext.subscriptions.filter((entry) => entry instanceof CodeIndexScope)).toHaveLength(1) + } finally { + init.mockRestore() + } + }, + ) + test("does not call dotenv.config when optional .env does not exist", async () => { vi.resetModules() vi.clearAllMocks() diff --git a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts index 03789f7d40..f2c4cc2aed 100644 --- a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts +++ b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts @@ -6,6 +6,7 @@ import type { ToolCallbacks } from "../BaseTool" import { CodebaseSearchTool } from "../CodebaseSearchTool" import { CodeIndexManagerRegistry } from "../../../services/code-index/code-index-manager-registry" import { CodeIndexManager } from "../../../services/code-index/manager" +import { CodeIndexStateManager } from "../../../services/code-index/state-manager" import { getWorkspacePath } from "../../../utils/path" import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" @@ -15,6 +16,7 @@ vi.mock("vscode", () => ({ Uri: { file: vi.fn() }, })) vi.mock("../../../utils/path", () => ({ getWorkspacePath: vi.fn() })) +vi.mock("../../../services/code-index/state-manager") vi.mock("../../../services/code-index/manager", () => ({ CodeIndexManager: vi.fn().mockImplementation(function (workspacePath: string) { let initialized = false @@ -196,6 +198,7 @@ describe("CodebaseSearchTool workspace selection", () => { "/external-task", expect.objectContaining({ fsPath: "/external-task" }), provider.context, + expect.any(CodeIndexStateManager), ) expect(vscode.Uri.file).toHaveBeenCalledExactlyOnceWith("/external-task") const manager = CodeIndexManagerRegistry.getOrCreate(provider.context, "/external-task")! diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 185cfbb427..6fac386383 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -92,7 +92,6 @@ import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import type { CodeIndexManager } from "../../services/code-index/manager" import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" -import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -212,8 +211,6 @@ export class ClineProvider private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() private cancelledDelegationChildIds = new Set() - private codeIndexStatusSubscription?: vscode.Disposable - private codeIndexManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager @@ -1045,17 +1042,6 @@ export class ClineProvider // and executes code based on the message that is received. this.setWebviewMessageListener(webviewView.webview) - // Initialize code index status subscription for the current workspace. - this.updateCodeIndexStatusSubscription() - - // Listen for active editor changes to update code index status for the - // current workspace. - const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => { - // Update subscription when workspace might have changed. - this.updateCodeIndexStatusSubscription() - }) - this.webviewDisposables.push(activeEditorSubscription) - // Listen for when the panel becomes visible. // https://github.com/microsoft/vscode-discussions/discussions/840 if ("onDidChangeViewState" in webviewView) { @@ -1093,8 +1079,6 @@ export class ClineProvider } else { this.log("Clearing webview resources for sidebar view") this.clearWebviewResources() - // Reset current workspace manager reference when view is disposed - this.codeIndexManager = undefined } }, null, @@ -3243,53 +3227,6 @@ export class ClineProvider return CodeIndexManagerRegistry.getOrCreate(this.context) } - /** - * Updates the code index status subscription to listen to the current workspace manager - */ - private updateCodeIndexStatusSubscription(): void { - // Get the current workspace manager - const currentManager = this.getCurrentWorkspaceCodeIndexManager() - - // If the manager hasn't changed, no need to update subscription - if (currentManager === this.codeIndexManager) { - return - } - - // Dispose the old subscription if it exists - if (this.codeIndexStatusSubscription) { - this.codeIndexStatusSubscription.dispose() - this.codeIndexStatusSubscription = undefined - } - - // Update the current workspace manager reference - this.codeIndexManager = currentManager - - // Subscribe to the new manager's progress updates if it exists - if (currentManager) { - this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { - // Only send updates if this manager is still the current one - if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { - // Get the full status from the manager to ensure we have all fields correctly formatted - const fullStatus = currentManager.getCurrentStatus() - void this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: fullStatus, - }) - } - }) - - if (this.view) { - this.webviewDisposables.push(this.codeIndexStatusSubscription) - } - - // Send initial status for the current workspace - void this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentManager.getCurrentStatus(), - }) - } - } - /** * TaskProviderLike, TelemetryPropertiesProvider */ @@ -3777,6 +3714,11 @@ export class ClineProvider } } + /** Workspace explicitly associated with this provider, without an active-editor fallback. */ + public get workspacePath(): string | undefined { + return this.currentWorkspacePath + } + public get cwd() { return this.currentWorkspacePath || getWorkspacePath() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 72aa1bcb76..feef95d870 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -561,6 +561,15 @@ describe("ClineProvider", () => { }) }) + test("exposes only the explicitly associated workspace without a fallback", () => { + provider["currentWorkspacePath"] = "/workspace-a" + expect(provider.workspacePath).toBe("/workspace-a") + provider["currentWorkspacePath"] = "/workspace-b" + expect(provider.workspacePath).toBe("/workspace-b") + provider["currentWorkspacePath"] = undefined + expect(provider.workspacePath).toBeUndefined() + }) + test("constructor initializes correctly", () => { expect(provider).toBeInstanceOf(ClineProvider) // Since getVisibleInstance returns the last instance where view.visible is true diff --git a/src/extension.ts b/src/extension.ts index 8706de765b..1d71f834df 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,6 +35,7 @@ import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" +import { CodeIndexScope } from "./services/code-index/code-index-scope" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -195,6 +196,15 @@ export async function activate(context: vscode.ExtensionContext) { }), ) + const codeIndexScope = new CodeIndexScope(context) + context.subscriptions.push(codeIndexScope) + try { + codeIndexScope.init() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + outputChannel.appendLine(`[CodeIndexScope] Failed to initialize: ${message}`) + } + // Initialize code index managers for all workspace folders. if (vscode.workspace.workspaceFolders) { for (const folder of vscode.workspace.workspaceFolders) { diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts index 28ecb5d6f0..bfbf58da8f 100644 --- a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts +++ b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts @@ -3,6 +3,9 @@ import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from import { CodeIndexManager } from "../manager" import { CodeIndexManagerRegistry } from "../code-index-manager-registry" import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" +import { CodeIndexStateManager } from "../state-manager" + +vi.mock("../state-manager") vi.mock("vscode", () => ({ workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, @@ -45,7 +48,7 @@ describe("CodeIndexManagerRegistry", () => { it("uses the first workspace when there is no active editor", () => { CodeIndexManagerRegistry.getOrCreate(context) - expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context, expect.any(CodeIndexStateManager)) }) it("prefers the active editor's workspace", () => { @@ -53,20 +56,20 @@ describe("CodeIndexManagerRegistry", () => { Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: editor }) vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) expect(CodeIndexManagerRegistry.getOrCreate(context)).toBeDefined() - expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context, expect.any(CodeIndexStateManager)) }) it("falls back to the first workspace for an editor outside all folders", () => { Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) CodeIndexManagerRegistry.getOrCreate(context) - expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context, expect.any(CodeIndexStateManager)) }) it("gives an explicit path priority over the active editor", () => { Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) expect(CodeIndexManagerRegistry.getOrCreate(context, "/second")).toBeDefined() - expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context, expect.any(CodeIndexStateManager)) }) it("preserves the actual remote workspace URI", () => { @@ -76,7 +79,7 @@ describe("CodeIndexManagerRegistry", () => { value: [{ uri, name: "remote", index: 0 }], }) CodeIndexManagerRegistry.getOrCreate(context, "/remote") - expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context, expect.any(CodeIndexStateManager)) expect(vi.mocked(CodeIndexManager).mock.calls[0][1]).toBe(uri) expect(vscode.Uri.file).not.toHaveBeenCalled() }) @@ -87,7 +90,7 @@ describe("CodeIndexManagerRegistry", () => { vi.mocked(vscode.Uri.file).mockReturnValue(uri) CodeIndexManagerRegistry.getOrCreate(context, uri.fsPath) expect(vscode.Uri.file).toHaveBeenCalledWith(uri.fsPath) - expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context, expect.any(CodeIndexStateManager)) }) it("constructs a file URI for an explicit path not matching any open workspace folder", () => { @@ -96,7 +99,12 @@ describe("CodeIndexManagerRegistry", () => { vi.mocked(vscode.Uri.file).mockReturnValue(uri) CodeIndexManagerRegistry.getOrCreate(context, "/outside/project") expect(vscode.Uri.file).toHaveBeenCalledWith("/outside/project") - expect(CodeIndexManager).toHaveBeenCalledWith("/outside/project", uri, context) + expect(CodeIndexManager).toHaveBeenCalledWith( + "/outside/project", + uri, + context, + expect.any(CodeIndexStateManager), + ) }) it("reuses the same path and keeps different paths isolated", () => { diff --git a/src/services/code-index/__tests__/code-index-scope.spec.ts b/src/services/code-index/__tests__/code-index-scope.spec.ts new file mode 100644 index 0000000000..2a5692c817 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-scope.spec.ts @@ -0,0 +1,49 @@ +import { makeExtensionContext } from "../../../test-utils/vscode" +import { CodeIndexScope } from "../code-index-scope" +import { CodeIndexStatusManager } from "../code-index-status-manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" + +vi.mock("../code-index-status-manager") +vi.mock("../code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getOrCreate: vi.fn() }, +})) + +describe("CodeIndexScope", () => { + beforeEach(() => vi.clearAllMocks()) + + it("creates, initializes and disposes its status manager", () => { + const context = makeExtensionContext() + const scope = new CodeIndexScope(context) + expect(scope["_isInitialized"]).toBe(false) + expect(CodeIndexStatusManager).not.toHaveBeenCalled() + scope.init() + expect(scope["_isInitialized"]).toBe(true) + const manager = vi.mocked(CodeIndexStatusManager).mock.instances[0] + const [resolve] = vi.mocked(CodeIndexStatusManager).mock.calls[0] + resolve("/first") + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenCalledExactlyOnceWith(context, "/first") + expect(manager.init).toHaveBeenCalledExactlyOnceWith() + expect(() => scope.init()).toThrow("already initialized") + scope.dispose() + expect(scope["_isInitialized"]).toBe(false) + scope.dispose() + expect(manager.dispose).toHaveBeenCalledExactlyOnceWith() + scope.init() + expect(CodeIndexStatusManager).toHaveBeenCalledTimes(2) + scope.dispose() + }) + + it("can dispose before initialization and retry failed initialization", () => { + const scope = new CodeIndexScope(makeExtensionContext()) + scope.dispose() + expect(CodeIndexStatusManager).not.toHaveBeenCalled() + vi.mocked(CodeIndexStatusManager.prototype.init).mockImplementationOnce(() => { + throw new Error("init failed") + }) + expect(() => scope.init()).toThrow("init failed") + expect(scope["_isInitialized"]).toBe(false) + expect(() => scope.init()).not.toThrow() + expect(scope["_isInitialized"]).toBe(true) + scope.dispose() + }) +}) diff --git a/src/services/code-index/__tests__/code-index-status-manager.spec.ts b/src/services/code-index/__tests__/code-index-status-manager.spec.ts new file mode 100644 index 0000000000..c73fb4f03b --- /dev/null +++ b/src/services/code-index/__tests__/code-index-status-manager.spec.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode" +import { + makeUri, + makeTextDocument, + makeTextEditor, + makeExtensionContext, + makeWorkspaceConfiguration, +} from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexStateManager } from "../state-manager" +import { CodeIndexStatusManager, type CodeIndexStatus } from "../code-index-status-manager" + +// Reload the real workspace resolver against this suite's VS Code mock, +// rather than the VS Code instance cached by the global test setup. +vi.hoisted(() => vi.resetModules()) + +vi.mock("../../../core/webview/ClineProvider", () => ({ ClineProvider: { getAllInstances: vi.fn(() => []) } })) +vi.mock("../manager") +vi.mock("../state-manager") + +vi.mock("vscode", () => ({ + window: { onDidChangeActiveTextEditor: vi.fn(), activeTextEditor: undefined }, + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn(), getConfiguration: vi.fn() }, +})) + +function makeSource(workspacePath: string) { + const status: CodeIndexStatus = { + systemStatus: "Standby", + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath, + workspaceEnabled: true, + autoEnableDefault: true, + } + let emit = () => {} + const subscription = { dispose: vi.fn() } + const event: CodeIndexManager["onProgressUpdate"] = (listener) => { + emit = () => listener(status) + return subscription + } + const manager = new CodeIndexManager( + workspacePath, + makeUri(workspacePath), + makeExtensionContext(), + new CodeIndexStateManager(), + ) + Object.defineProperty(manager, "onProgressUpdate", { value: vi.fn(event), configurable: true }) + return Object.assign(manager, { + getCurrentStatus: vi.fn(() => status), + dispose: vi.fn(), + subscription, + emit: () => emit(), + status, + }) +} + +describe("CodeIndexStatusManager", () => { + let editorChanged: () => void + let disposeEditor: ReturnType void>> + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(makeWorkspaceConfiguration()) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri: makeUri("/first"), name: "first", index: 0 }], + }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + disposeEditor = vi.fn() + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation((listener) => { + editorChanged = () => listener(undefined) + return { dispose: disposeEditor } + }) + }) + + it("resolves the workspace before requesting its manager", () => { + const resolve = vi.fn(() => makeSource("/first")) + const manager = new CodeIndexStatusManager(resolve) + manager.init() + expect(resolve).toHaveBeenLastCalledWith("/first") + const uri = makeUri("/second/file.ts") + Object.defineProperty(vscode.window, "activeTextEditor", { + configurable: true, + value: makeTextEditor({ document: makeTextDocument({ uri }) }), + }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue({ + uri: makeUri("/second"), + name: "second", + index: 1, + }) + editorChanged() + expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(uri) + expect(resolve).toHaveBeenLastCalledWith("/second") + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + editorChanged() + expect(resolve).toHaveBeenLastCalledWith("/first") + manager.dispose() + }) + + it.each([ + ["firstFolder", "/first"], + ["activeEditor", "/second"], + ])("publishes status for the configured %s workspace", (strategy, expectedPath) => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + makeWorkspaceConfiguration({ "workspace.rootResolution": strategy }), + ) + const firstFolder = { uri: makeUri("/first"), name: "first", index: 0 } + const secondFolder = { uri: makeUri("/second"), name: "second", index: 1 } + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [firstFolder, secondFolder], + }) + Object.defineProperty(vscode.window, "activeTextEditor", { + configurable: true, + value: makeTextEditor({ document: makeTextDocument({ uri: makeUri("/second/file.ts") }) }), + }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(secondFolder) + const first = makeSource("/first") + const second = makeSource("/second") + const resolve = vi.fn((path: string) => (path === "/first" ? first : second)) + const publish = vi.fn() + const manager = new CodeIndexStatusManager(resolve) + manager["publishStatus"] = publish + try { + manager.init() + expect(resolve).toHaveBeenLastCalledWith(expectedPath) + const selected = expectedPath === "/first" ? first : second + const other = selected === first ? second : first + expect(publish).toHaveBeenCalledExactlyOnceWith(selected.status) + expect(other.onProgressUpdate).not.toHaveBeenCalled() + editorChanged() + selected.status.message = "Updated" + selected.emit() + expect(publish).toHaveBeenLastCalledWith(selected.status) + expect(publish).toHaveBeenCalledTimes(2) + expect(selected.onProgressUpdate).toHaveBeenCalledTimes(1) + } finally { + manager.dispose() + } + }) + + it.each([undefined, []])("does not request a manager without a workspace: %s", (folders) => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) + const resolve = vi.fn() + const publish = vi.fn() + const manager = new CodeIndexStatusManager(resolve) + manager["publishStatus"] = publish + manager.init() + editorChanged() + expect(resolve).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + manager.dispose() + }) + + it("publishes initial and full updated status without resubscribing in the same workspace", () => { + const source = makeSource("/first") + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => source) + manager["publishStatus"] = publish + expect(source.onProgressUpdate).not.toHaveBeenCalled() + manager.init() + expect(publish).toHaveBeenCalledExactlyOnceWith(source.status) + source.status.message = "Updated" + source.emit() + expect(publish).toHaveBeenLastCalledWith(source.status) + expect(publish).toHaveBeenCalledTimes(2) + editorChanged() + expect(source.onProgressUpdate).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenCalledTimes(2) + manager.dispose() + }) + + it("switches subscriptions and rejects stale events before and after switching", () => { + const first = makeSource("/first") + const second = makeSource("/second") + let current = first + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => current) + manager["publishStatus"] = publish + manager.init() + current = second + first.emit() + expect(publish).toHaveBeenCalledTimes(1) + editorChanged() + expect(first.subscription.dispose).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenLastCalledWith(second.status) + first.emit() + expect(publish).toHaveBeenCalledTimes(2) + second.emit() + expect(publish).toHaveBeenCalledTimes(3) + manager.dispose() + expect(first.dispose).not.toHaveBeenCalled() + expect(second.dispose).not.toHaveBeenCalled() + }) + + it("handles missing workspaces and releases the subscription when a workspace disappears", () => { + const source = makeSource("/first") + let current: typeof source | undefined + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => current) + manager["publishStatus"] = publish + manager.init() + expect(publish).not.toHaveBeenCalled() + current = source + editorChanged() + expect(publish).toHaveBeenCalledExactlyOnceWith(source.status) + current = undefined + editorChanged() + expect(source.subscription.dispose).toHaveBeenCalledTimes(1) + source.emit() + expect(publish).toHaveBeenCalledTimes(1) + manager.dispose() + }) + + it("disposes idempotently, ignores stale progress and supports reinitialization", () => { + const source = makeSource("/first") + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => source) + manager["publishStatus"] = publish + manager.dispose() + manager.init() + manager.dispose() + manager.dispose() + expect(disposeEditor).toHaveBeenCalledTimes(1) + expect(source.subscription.dispose).toHaveBeenCalledTimes(1) + source.emit() + expect(publish).toHaveBeenCalledTimes(1) + manager.init() + expect(publish).toHaveBeenCalledTimes(2) + manager.dispose() + }) + + it("logs both unsubscribe failures and keeps disposal idempotent", () => { + const source = makeSource("/first") + const manager = new CodeIndexStatusManager(() => source) + const editorError = new Error("editor cleanup failed") + const progressError = new Error("progress cleanup failed") + const log = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + manager.init() + disposeEditor.mockImplementationOnce(() => { + throw editorError + }) + source.subscription.dispose.mockImplementationOnce(() => { + throw progressError + }) + expect(() => manager.dispose()).not.toThrow() + expect(log).toHaveBeenNthCalledWith( + 1, + "[CodeIndexStatusManager] Failed to dispose active editor subscription:", + editorError, + ) + expect(log).toHaveBeenNthCalledWith( + 2, + "[CodeIndexStatusManager] Failed to dispose progress subscription:", + progressError, + ) + manager.dispose() + expect(disposeEditor).toHaveBeenCalledTimes(1) + expect(source.subscription.dispose).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledTimes(2) + } finally { + log.mockRestore() + } + }) + + it("logs an unsubscribe failure and still subscribes to the next workspace", () => { + const first = makeSource("/first") + const second = makeSource("/second") + let current = first + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => current) + manager["publishStatus"] = publish + const log = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + manager.init() + first.subscription.dispose.mockImplementationOnce(() => { + throw "cleanup failed" + }) + current = second + expect(() => editorChanged()).not.toThrow() + expect(log).toHaveBeenCalledExactlyOnceWith( + "[CodeIndexStatusManager] Failed to dispose progress subscription:", + "cleanup failed", + ) + expect(second.onProgressUpdate).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenLastCalledWith(second.status) + first.emit() + expect(publish).toHaveBeenCalledTimes(2) + } finally { + manager.dispose() + log.mockRestore() + } + }) + + it("propagates initialization failure without automatic cleanup", () => { + const source = makeSource("/first") + const publish = vi.fn().mockImplementationOnce(() => { + throw new Error("publish failed") + }) + const manager = new CodeIndexStatusManager(() => source) + manager["publishStatus"] = publish + expect(() => manager.init()).toThrow("publish failed") + expect(disposeEditor).not.toHaveBeenCalled() + expect(source.subscription.dispose).not.toHaveBeenCalled() + manager.dispose() + }) +}) diff --git a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts index 2e20f07799..0379df7feb 100644 --- a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts +++ b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts @@ -1,6 +1,9 @@ import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { CodeIndexManager } from "../manager" import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" +import { CodeIndexStateManager } from "../state-manager" + +vi.mock("../state-manager") vi.mock("../manager", () => ({ CodeIndexManager: vi.fn().mockImplementation(function () { @@ -27,11 +30,20 @@ describe("CodeIndexWorkspaceScope", () => { const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, context) expect(CodeIndexManager).not.toHaveBeenCalled() + expect(CodeIndexStateManager).not.toHaveBeenCalled() + expect(scope["_stateManager"]).toBeUndefined() expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") expect(scope.init()).toBeUndefined() const manager = scope.codeIndexManager - expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith(uri.fsPath, uri, context) + expect(CodeIndexStateManager).toHaveBeenCalledExactlyOnceWith() + expect(scope["_stateManager"]).toBe(vi.mocked(CodeIndexStateManager).mock.instances[0]) + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith( + uri.fsPath, + uri, + context, + vi.mocked(CodeIndexStateManager).mock.instances[0], + ) expect(scope.codeIndexManager).toBe(manager) expect(() => scope.init()).toThrow("Code index workspace scope is already initialized") expect(scope.codeIndexManager).toBe(manager) @@ -45,6 +57,7 @@ describe("CodeIndexWorkspaceScope", () => { const manager = scope.codeIndexManager expect(scope.dispose()).toBeUndefined() + expect(scope["_stateManager"]).toBeUndefined() expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") scope.dispose() expect(manager.dispose).toHaveBeenCalledExactlyOnceWith() @@ -68,6 +81,24 @@ describe("CodeIndexWorkspaceScope", () => { expect(scope.codeIndexManager).toBe(current) expect(CodeIndexManager).toHaveBeenCalledTimes(2) expect(current.dispose).not.toHaveBeenCalled() + const calls = vi.mocked(CodeIndexManager).mock.calls + expect(CodeIndexStateManager).toHaveBeenCalledTimes(2) + expect(calls[1][3]).not.toBe(calls[0][3]) + expect(scope["_stateManager"]).toBe(calls[1][3]) + }) + + it("creates separate state managers for different workspace scopes", () => { + const context = makeExtensionContext() + const first = new CodeIndexWorkspaceScope("/first", makeUri("/first"), context) + const second = new CodeIndexWorkspaceScope("/second", makeUri("/second"), context) + first.init() + second.init() + + const calls = vi.mocked(CodeIndexManager).mock.calls + expect(CodeIndexStateManager).toHaveBeenCalledTimes(2) + expect(calls[0][3]).toBe(vi.mocked(CodeIndexStateManager).mock.instances[0]) + expect(calls[1][3]).toBe(vi.mocked(CodeIndexStateManager).mock.instances[1]) + expect(calls[1][3]).not.toBe(calls[0][3]) }) it("clears its reference even when manager disposal throws", () => { @@ -79,6 +110,7 @@ describe("CodeIndexWorkspaceScope", () => { throw error }) expect(() => scope.dispose()).toThrow(error) + expect(scope["_stateManager"]).toBeUndefined() expect(() => scope.codeIndexManager).toThrow("Code index workspace scope is not initialized") scope.dispose() expect(manager.dispose).toHaveBeenCalledTimes(1) diff --git a/src/services/code-index/code-index-scope.ts b/src/services/code-index/code-index-scope.ts new file mode 100644 index 0000000000..8b6b36c2cd --- /dev/null +++ b/src/services/code-index/code-index-scope.ts @@ -0,0 +1,29 @@ +import type * as vscode from "vscode" +import { CodeIndexManagerRegistry } from "./code-index-manager-registry" +import { CodeIndexStatusManager } from "./code-index-status-manager" + +export class CodeIndexScope implements vscode.Disposable { + private statusManager?: CodeIndexStatusManager + private _isInitialized = false + + public constructor(private readonly context: vscode.ExtensionContext) {} + + public init(): void { + if (this._isInitialized) { + throw new Error("Code index scope is already initialized") + } + const manager = new CodeIndexStatusManager((workspacePath) => + CodeIndexManagerRegistry.getOrCreate(this.context, workspacePath), + ) + manager.init() + this.statusManager = manager + this._isInitialized = true + } + + public dispose(): void { + const manager = this.statusManager + this.statusManager = undefined + this._isInitialized = false + manager?.dispose() + } +} diff --git a/src/services/code-index/code-index-status-manager.ts b/src/services/code-index/code-index-status-manager.ts new file mode 100644 index 0000000000..2461630eaa --- /dev/null +++ b/src/services/code-index/code-index-status-manager.ts @@ -0,0 +1,79 @@ +import * as vscode from "vscode" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { getWorkspacePath } from "../../utils/path" +import type { CodeIndexManager } from "./manager" + +export type CodeIndexStatus = ReturnType + +/** Tracks the active workspace; owns subscriptions, never workspace managers. */ +export class CodeIndexStatusManager implements vscode.Disposable { + private currentManager?: CodeIndexManager + private progressSubscription?: vscode.Disposable + private editorSubscription?: vscode.Disposable + + public constructor( + private readonly getManagerForWorkspace: (workspacePath: string) => CodeIndexManager | undefined, + ) {} + + public init(): void { + this.editorSubscription = vscode.window.onDidChangeActiveTextEditor(() => this.updateSubscription()) + this.updateSubscription() + } + + private getCurrentManager(): CodeIndexManager | undefined { + const workspacePath = getWorkspacePath() + return workspacePath ? this.getManagerForWorkspace(workspacePath) : undefined + } + + private updateSubscription(): void { + const manager = this.getCurrentManager() + if (manager === this.currentManager) return + + this.disposeSubscription(this.progressSubscription, "progress") + this.progressSubscription = undefined + this.currentManager = manager + if (!manager) return + + this.progressSubscription = manager.onProgressUpdate(() => { + if (manager === this.currentManager && manager === this.getCurrentManager()) { + this.publishStatus(manager.getCurrentStatus()) + } + }) + this.publishStatus(manager.getCurrentStatus()) + } + + private publishStatus(status: CodeIndexStatus): void { + for (const provider of ClineProvider.getAllInstances()) { + void this.publishStatusToProvider(provider, status) + } + } + + private async publishStatusToProvider(provider: ClineProvider, status: CodeIndexStatus): Promise { + try { + const workspacePath = provider.workspacePath + if (workspacePath && workspacePath !== status.workspacePath) return + + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: status }) + } catch (error) { + console.error("[CodeIndexStatusManager] Failed to publish indexing status:", error) + } + } + + private disposeSubscription(subscription: vscode.Disposable | undefined, name: string): void { + try { + subscription?.dispose() + } catch (error) { + console.error(`[CodeIndexStatusManager] Failed to dispose ${name} subscription:`, error) + } + } + + public dispose(): void { + const editorSubscription = this.editorSubscription + const progressSubscription = this.progressSubscription + this.currentManager = undefined + this.editorSubscription = undefined + this.progressSubscription = undefined + this.disposeSubscription(editorSubscription, "active editor") + this.disposeSubscription(progressSubscription, "progress") + } +} diff --git a/src/services/code-index/code-index-workspace-scope.ts b/src/services/code-index/code-index-workspace-scope.ts index 683a783335..0f187fd749 100644 --- a/src/services/code-index/code-index-workspace-scope.ts +++ b/src/services/code-index/code-index-workspace-scope.ts @@ -1,10 +1,12 @@ import type * as vscode from "vscode" import { CodeIndexManager } from "./manager" +import { CodeIndexStateManager } from "./state-manager" /** Owns code-index services for one workspace; initialization remains with existing callers. */ export class CodeIndexWorkspaceScope implements vscode.Disposable { private _codeIndexManager?: CodeIndexManager + private _stateManager?: CodeIndexStateManager private _isInitialized = false public constructor( @@ -29,13 +31,20 @@ export class CodeIndexWorkspaceScope implements vscode.Disposable { if (this._isInitialized) { throw new Error("Code index workspace scope is already initialized") } - this._codeIndexManager = new CodeIndexManager(this.workspacePath, this.folderUri, this.context) + this._stateManager = new CodeIndexStateManager() + this._codeIndexManager = new CodeIndexManager( + this.workspacePath, + this.folderUri, + this.context, + this._stateManager, + ) this._isInitialized = true } public dispose(): void { const manager = this._codeIndexManager this._codeIndexManager = undefined + this._stateManager = undefined this._isInitialized = false manager?.dispose() } diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index ee181b17d6..bb186dc105 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -3,7 +3,7 @@ import { ContextProxy } from "../../core/config/ContextProxy" import { VectorStoreSearchResult } from "./interfaces" import { IndexingState } from "./interfaces/manager" import { CodeIndexConfigManager } from "./config-manager" -import { CodeIndexStateManager } from "./state-manager" +import type { CodeIndexStateManager } from "./state-manager" import { CodeIndexServiceFactory } from "./service-factory" import { CodeIndexSearchService } from "./search-service" import { CodeIndexOrchestrator } from "./orchestrator" @@ -35,11 +35,16 @@ export class CodeIndexManager { private readonly context: vscode.ExtensionContext /** @internal — construct only via {@link CodeIndexManagerRegistry} */ - public constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + public constructor( + workspacePath: string, + folderUri: vscode.Uri, + context: vscode.ExtensionContext, + stateManager: CodeIndexStateManager, + ) { this.workspacePath = workspacePath this._folderUri = folderUri this.context = context - this._stateManager = new CodeIndexStateManager() + this._stateManager = stateManager } // --- Public API --- From 91f50a4ccd74cd55c1cc025c1729400901eea96d Mon Sep 17 00:00:00 2001 From: seeones Date: Tue, 29 Sep 2026 11:37:41 +0800 Subject: [PATCH 11/35] fix(chat): harden inline model selector (allow-list, a11y, Z.AI line, race) - Enforce the organization allow-list on both the webview and the extension host - Make the selector trigger keyboard accessible (div -> button) - Correct Z.AI China API line handling so the mainland model list loads - Guard the OpenAI model list request against identity races - Show the currently saved model in the trigger --- src/core/webview/ClineProvider.ts | 26 +++++++ src/core/webview/webviewMessageHandler.ts | 30 +++++++- .../src/components/chat/ChatModelSelector.tsx | 69 +++++++++++++++---- .../chat/hooks/useChatModelSelector.ts | 40 ++++++++++- 4 files changed, 149 insertions(+), 16 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6fac386383..b96efcd193 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1836,6 +1836,32 @@ export class ClineProvider providerSettings: ProviderSettings, activate: boolean = true, ): Promise { + // Enforce the organization model allow-list before persisting or + // activating a profile. The webview is not a trusted boundary, so the + // model selector's client-side gating cannot be the only check. + // Task creation validates too, but rejecting here prevents an + // unauthorized profile from being written or activated at all. + let organizationAllowList = ORGANIZATION_ALLOW_ALL + + try { + organizationAllowList = await CloudService.instance.getAllowList() + } catch (error) { + // No cloud instance / not authenticated: fall back to allow-all, + // matching `getState` and task-creation semantics. + this.log( + `[upsertProviderProfile] organization allow-list unavailable, using allow-all: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + if (!ProfileValidator.isProfileAllowed(providerSettings, organizationAllowList)) { + this.log( + `[upsertProviderProfile] Blocked profile "${name}": model is not allowed by the organization allow-list`, + ) + return undefined + } + try { return await this.enqueueProviderProfileMutation(async (signal) => { // TODO: Do we need to be calling `activateProfile`? It's not diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a41fda1ce1..7ebd125a7e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,6 +24,7 @@ import { getCompletionCheckpoint, providerIdentifiers, retiredProviderIdentifiers, + ORGANIZATION_ALLOW_ALL, LmStudioModelsMessageType, OllamaModelsMessageType, OpenAiModelsMessageType, @@ -57,6 +58,7 @@ import { handleOpenRuleFile, handleOpenRulesDirectory, } from "./rulesMessageHandler" +import { ProfileValidator } from "../../shared/ProfileValidator" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" @@ -1475,7 +1477,14 @@ export const webviewMessageHandler = async ( message?.values?.openAiHeaders, ) - await provider.postMessageToWebview({ type: OpenAiModelsMessageType.openAiModels, openAiModels }) + // Echo the caller's request id so the webview can correlate the + // response with the request it issued and drop stale replies + // that arrive after a provider/profile switch. + await provider.postMessageToWebview({ + type: OpenAiModelsMessageType.openAiModels, + openAiModels, + requestId: message.requestId, + }) } break @@ -2280,6 +2289,25 @@ export const webviewMessageHandler = async ( break case "upsertApiConfiguration": if (message.text && message.apiConfiguration) { + const { organizationAllowList } = await provider.getState() + + // The webview is not a trusted boundary: re-validate the + // organization model allow-list before persisting and + // activating, so a forged custom model id cannot bypass the + // selector's UI gating. + if ( + !ProfileValidator.isProfileAllowed( + message.apiConfiguration, + organizationAllowList ?? ORGANIZATION_ALLOW_ALL, + ) + ) { + provider.log( + `Blocked upsertApiConfiguration "${message.text}": model is not allowed by the organization allow-list`, + ) + vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) + break + } + await provider.upsertProviderProfile(message.text, message.apiConfiguration) } break diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx index 7cd3450e01..0bbf70d1fe 100644 --- a/webview-ui/src/components/chat/ChatModelSelector.tsx +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -40,14 +40,47 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = return available }, [models, provider, organizationAllowList, selectedModelId]) + // Gate arbitrary model ids against the organization allow-list. The webview + // is not a security boundary, but this keeps the UI from offering a custom + // model that the extension host would reject on save. + const isModelAllowed = useCallback( + (modelId: string): boolean => { + if (!organizationAllowList || organizationAllowList.allowAll) { + return true + } + if (!provider) { + return false + } + const providerConfig = organizationAllowList.providers[provider] + if (!providerConfig) { + return false + } + if (providerConfig.allowAll) { + return true + } + return providerConfig.models?.includes(modelId) ?? false + }, + [organizationAllowList, provider], + ) + + // Only offer a custom (non-listed) model when the allow-list permits that + // exact model id for the active provider. + const customModelAllowed = useMemo( + () => (searchValue ? isModelAllowed(searchValue) : false), + [searchValue, isModelAllowed], + ) + // Resolve the display value (custom transform for compound config values like VSCode LM). const displayValue = useMemo(() => { if (displayTransform && modelIdKey) { const storedValue = apiConfiguration?.[modelIdKey] return storedValue ? displayTransform(storedValue) : undefined } - return selectedModelId || (searchValue ? searchValue : undefined) - }, [displayTransform, modelIdKey, apiConfiguration, selectedModelId, searchValue]) + // Only reflect the saved model in the trigger label (and the row + // highlight); fall back to undefined so the default/placeholder shows + // instead of the in-progress search text. + return selectedModelId || undefined + }, [displayTransform, modelIdKey, apiConfiguration, selectedModelId]) const filteredModelIds = useMemo(() => { if (!searchValue) return modelIds @@ -61,6 +94,12 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = return } + // Defense in depth: never persist a model the allow-list forbids, + // even if a caller bypasses the rendered list (e.g. custom search). + if (!isModelAllowed(modelId)) { + return + } + setOpen(false) setSearchValue("") @@ -79,7 +118,7 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = }, }) }, - [modelIdKey, apiConfiguration, valueTransform, currentApiConfigName], + [modelIdKey, apiConfiguration, valueTransform, currentApiConfigName, isModelAllowed], ) const onClearSearch = useCallback(() => { @@ -123,9 +162,12 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = /> {searchValue.length > 0 && (

)} @@ -138,12 +180,14 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = {modelIds.length > 0 && (
{filteredModelIds.map((modelId) => ( -
onSelect(modelId)} data-testid={`chat-model-option-${modelId}`} className={cn( - "px-3 py-1.5 text-sm cursor-pointer flex items-center gap-2", + "w-full text-left bg-transparent border-none px-3 py-1.5 text-sm cursor-pointer flex items-center gap-2", + "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset", "hover:bg-vscode-list-hoverBackground", modelId === displayValue && "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", @@ -152,18 +196,19 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = {modelId} {modelId === displayValue && } -
+ ))}
)} - {searchValue && !modelIds.includes(searchValue) && ( -
onSelect(searchValue)} - className="px-3 py-1.5 text-sm cursor-pointer hover:bg-vscode-list-hoverBackground border-t border-vscode-input-border"> + className="w-full text-left bg-transparent border-none px-3 py-1.5 text-sm cursor-pointer hover:bg-vscode-list-hoverBackground border-t border-vscode-input-border focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset"> {t("chat:useCustomModel", { modelId: searchValue })} -
+ )}
diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts index 07cfbbe77f..7ab0dc2b9d 100644 --- a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import { @@ -141,7 +141,7 @@ export interface ChatModelSelectorData { * - Static providers: `getStaticModelsForProvider`. */ export const useChatModelSelector = (): ChatModelSelectorData => { - const { apiConfiguration, routerModels: stateRouterModels } = useExtensionState() + const { apiConfiguration, routerModels: stateRouterModels, currentApiConfigName } = useExtensionState() const provider = (apiConfiguration?.apiProvider || providerIdentifiers.openrouter) as ProviderName const activeProvider = isRetiredProvider(provider) ? undefined : provider @@ -159,10 +159,32 @@ export const useChatModelSelector = (): ChatModelSelectorData => { const [lmStudioModels, setLmStudioModels] = useState({}) const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + // Identity of the in-flight OpenAI-compatible model request. Responses that + // do not match the current request are ignored so a late reply from a + // previous provider/profile cannot overwrite the active list. + const openAiRequestIdRef = useRef(null) + + // Drop any previously fetched message-based model list when the provider or + // API profile changes; the lists are provider/profile scoped and a stale + // entry would otherwise leak across the switch. + useEffect(() => { + setOpenAiModels([]) + setOllamaModels({}) + setLmStudioModels({}) + setVsCodeLmModels([]) + openAiRequestIdRef.current = null + }, [activeProvider, currentApiConfigName]) + const onMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "openAiModels": + // Only accept a response that belongs to the request we most + // recently issued; ignore replies without an identity (other + // callers) or from a superseded request. + if (message.requestId && message.requestId !== openAiRequestIdRef.current) { + break + } setOpenAiModels(message.openAiModels ?? []) break case "ollamaModels": @@ -188,8 +210,13 @@ export const useChatModelSelector = (): ChatModelSelectorData => { switch (activeProvider) { case providerIdentifiers.openai: if (apiConfiguration?.openAiBaseUrl && apiConfiguration?.openAiApiKey) { + // Tag the request so the matching response can be identified + // and stale replies discarded (see `onMessage`). + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}` + openAiRequestIdRef.current = requestId vscode.postMessage({ type: "requestOpenAiModels", + requestId, values: { baseUrl: apiConfiguration.openAiBaseUrl, apiKey: apiConfiguration.openAiApiKey, @@ -211,6 +238,9 @@ export const useChatModelSelector = (): ChatModelSelectorData => { } }, [ activeProvider, + // Re-request after a provider/profile switch so the list matches the + // newly active profile (the reset effect clears the previous list). + currentApiConfigName, apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, apiConfiguration?.openAiHeaders, @@ -332,7 +362,11 @@ export const useChatModelSelector = (): ChatModelSelectorData => { break default: // Static models providers (anthropic, bedrock, gemini, etc.). - models = MODELS_BY_PROVIDER[activeProvider] ? getStaticModelsForProvider(activeProvider) : null + // Pass the configuration so Z.ai resolves models for the same + // API line (China vs international) as `defaultModelId`. + models = MODELS_BY_PROVIDER[activeProvider] + ? getStaticModelsForProvider(activeProvider, undefined, apiConfiguration) + : null modelIdKey = "apiModelId" } From 4d292444c35a52f95629cc617eea097dd1ff661c Mon Sep 17 00:00:00 2001 From: seeones Date: Tue, 29 Sep 2026 11:38:04 +0800 Subject: [PATCH 12/35] test(chat): strengthen inline model selector coverage - Cover allow-list gating, keyboard interaction, race handling and trigger display - Align assertions with the div -> button markup change --- .../chat/__tests__/ChatModelSelector.spec.tsx | 270 +++++++++++++++++- .../__tests__/useChatModelSelector.spec.tsx | 239 ++++++++++++++-- 2 files changed, 475 insertions(+), 34 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index 65642a1715..810298e6e0 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -88,11 +88,31 @@ const anthropicModels = { }, } +// Minimal ModelInfo factory for ad-hoc fixtures. +const modelInfo = (overrides: Record = {}) => ({ + maxTokens: 32000, + contextWindow: 200000, + supportsPromptCache: true, + ...overrides, +}) + describe("ChatModelSelector", () => { const defaultProps = { title: "Select model", } + /** Helper: render the selector and open the popover so options are visible. */ + const renderOpen = (disabled = false) => { + render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + } + + /** Helper: read the visible option ids in DOM order. */ + const visibleOptionIds = () => + screen + .getAllByTestId(/^chat-model-option-/) + .map((element) => element.getAttribute("data-testid")!.replace("chat-model-option-", "")) + beforeEach(() => { vi.clearAllMocks() mockUseExtensionState.mockReturnValue({ @@ -199,33 +219,72 @@ describe("ChatModelSelector", () => { expect((screen.getByPlaceholderText("chat:searchModel") as HTMLInputElement).value).toBe("") }) - test("does not post a message when selecting with no apiConfiguration", () => { + test("filters model ids case-insensitively", () => { mockUseChatModelSelector.mockReturnValue({ provider: providerIdentifiers.anthropic, - models: anthropicModels, + models: { + "Claude-Opus-4": modelInfo(), + "Claude-Sonnet-4": modelInfo(), + }, modelIdKey: "apiModelId", - defaultModelId: "claude-opus-4-20250514", + defaultModelId: "Claude-Opus-4", isLoading: false, }) - // Simulate a missing apiConfiguration so onSelect takes its early-return branch - // (the mock of useExtensionState returns an apiConfiguration; the real early - // return also triggers when modelIdKey is unset, which we cover via a hook mock - // without a modelIdKey below). - mockUseChatModelSelector.mockReturnValueOnce({ + + renderOpen() + + // Uppercase query must match the lowercase portion of the model id. + fireEvent.change(screen.getByPlaceholderText("chat:searchModel"), { target: { value: "SONNET" } }) + + expect(screen.getByTestId("chat-model-option-Claude-Sonnet-4")).toBeInTheDocument() + expect(screen.queryByTestId("chat-model-option-Claude-Opus-4")).not.toBeInTheDocument() + }) + + test("renders the empty state when no models are available", () => { + mockUseChatModelSelector.mockReturnValue({ provider: providerIdentifiers.anthropic, - models: anthropicModels, - modelIdKey: undefined, - defaultModelId: undefined, + models: {}, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", isLoading: false, }) + renderOpen() + + expect(screen.getByText("chat:modelListEmpty")).toBeInTheDocument() + expect(screen.queryByTestId(/^chat-model-option-/)).not.toBeInTheDocument() + }) + + test("shows a loading label and hides the empty state while loading", () => { + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: null, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", + isLoading: true, + }) + render() - const trigger = screen.getByTestId("chat-model-selector-trigger") - fireEvent.click(trigger) + // The trigger collapses to an ellipsis while loading, regardless of the + // resolved display/default value. + expect(screen.getByTestId("chat-model-selector-trigger")).toHaveTextContent("…") + // The empty-state message must not appear while a load is in flight. + expect(screen.queryByText("chat:modelListEmpty")).not.toBeInTheDocument() + }) - const option = screen.getByTestId("chat-model-option-claude-opus-4-20250514") - fireEvent.click(option) + test("does not post a message when selecting with no apiConfiguration", () => { + // Keep modelIdKey defined so the guard under test is the `!apiConfiguration` + // early return, not the `!modelIdKey` guard. + mockUseExtensionState.mockReturnValue({ + apiConfiguration: undefined, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + + renderOpen() + + fireEvent.click(screen.getByTestId("chat-model-option-claude-opus-4-20250514")) expect(vscode.postMessage).not.toHaveBeenCalled() }) @@ -257,4 +316,185 @@ describe("ChatModelSelector", () => { const trigger = screen.getByTestId("chat-model-selector-trigger") expect(trigger).toHaveTextContent("copilot/gpt-4o") }) + + describe("organization allow-list", () => { + test("filters models down to the provider allow-list", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { + allowAll: false, + models: ["claude-sonnet-4-20250514"], + }, + }, + }, + currentApiConfigName: "default", + }) + + renderOpen() + + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + expect(screen.queryByTestId("chat-model-option-claude-opus-4-20250514")).not.toBeInTheDocument() + }) + + test("shows the empty state when the provider is absent from the allow-list", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { allowAll: false, providers: {} }, + currentApiConfigName: "default", + }) + + renderOpen() + + expect(screen.queryByTestId(/^chat-model-option-/)).not.toBeInTheDocument() + expect(screen.getByText("chat:modelListEmpty")).toBeInTheDocument() + }) + + test("hides the custom-model row when the allow-list forbids the typed id", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { + allowAll: false, + models: ["claude-opus-4-20250514"], + }, + }, + }, + currentApiConfigName: "default", + }) + + renderOpen() + + fireEvent.change(screen.getByPlaceholderText("chat:searchModel"), { + target: { value: "unauthorized-model" }, + }) + + expect(screen.queryByTestId("chat-model-use-custom")).not.toBeInTheDocument() + }) + + test("offers and posts the custom-model row when the allow-list permits it", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { + allowAll: false, + models: ["claude-opus-4-20250514", "approved-custom"], + }, + }, + }, + currentApiConfigName: "default", + }) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: { "claude-opus-4-20250514": modelInfo() }, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", + isLoading: false, + }) + + renderOpen() + + fireEvent.change(screen.getByPlaceholderText("chat:searchModel"), { + target: { value: "approved-custom" }, + }) + + fireEvent.click(screen.getByTestId("chat-model-use-custom")) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "approved-custom", + }, + }) + }) + }) + + test("guards the visible list: sorts ids, drops unselected deprecated models and keeps the selected one", () => { + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + // Intentionally unsorted, with one deprecated model that is also the + // selected one (must survive) and one deprecated model that is not. + models: { + "zeta-model": modelInfo(), + "claude-opus-4-20250514": modelInfo({ deprecated: true }), + "legacy-model": modelInfo({ deprecated: true }), + "alpha-model": modelInfo(), + "middle-model": modelInfo(), + }, + modelIdKey: "apiModelId", + defaultModelId: "claude-opus-4-20250514", + isLoading: false, + }) + + renderOpen() + + // Rendered ids are sorted alphabetically and exclude the unselected deprecated model. + expect(visibleOptionIds()).toEqual(["alpha-model", "claude-opus-4-20250514", "middle-model", "zeta-model"]) + expect(screen.queryByTestId("chat-model-option-legacy-model")).not.toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toBeInTheDocument() + }) + + test("disables the trigger when there is no modelIdKey", () => { + mockUseChatModelSelector.mockReturnValue({ + provider: undefined, + models: null, + modelIdKey: undefined, + defaultModelId: "", + isLoading: false, + }) + + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeDisabled() + expect(trigger).toHaveClass("cursor-not-allowed") + }) + + test("exposes model options as keyboard-activatable buttons", () => { + renderOpen() + + const option = screen.getByTestId("chat-model-option-claude-sonnet-4-20250514") + // A native