diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index eba2426d02..2d1819b4e1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,9 +45,100 @@ jobs: VERSION=$(node -p 'require("./apps/vscode-e2e/package.json").devDependencies["@types/vscode"]') echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Cache VS Code test binary + - name: Restore VS Code test binary cache if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: vscode-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + apps/vscode-e2e/.vscode-test/ + key: vscode-test-${{ runner.os }}-${{ steps.vscode-ver.outputs.version }}-v1 + # Fall back to the most recent stale binary so a version bump or cache + # eviction doesn't force a download when the VS Code CDN is unreachable. + restore-keys: | + vscode-test-${{ runner.os }}- + + - name: Probe VS Code download endpoints + if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true' + id: vscode-probe + # The version-resolution API and the archive CDN are different hosts. The + # archive URL 302-redirects to the CDN, so a HEAD probe that follows the + # redirect covers both. The fallback engages when either is unreachable. + run: | + API_OK=false + CDN_OK=false + if curl -sf --max-time 10 https://update.code.visualstudio.com/api/releases/stable > /dev/null; then + API_OK=true + fi + if curl -sfIL --max-time 20 -o /dev/null "https://update.code.visualstudio.com/${{ steps.vscode-ver.outputs.version }}/linux-x64/stable"; then + CDN_OK=true + fi + if [ "$API_OK" = "true" ] && [ "$CDN_OK" = "true" ]; then + echo "reachable=true" >> "$GITHUB_OUTPUT" + else + echo "reachable=false" >> "$GITHUB_OUTPUT" + echo "::warning::VS Code download endpoints unreachable (api=$API_OK, cdn=$CDN_OK); will try the stale cached binary" + fi + + # @vscode/test-electron only skips its live version-resolution request when the + # requested version already exists in .vscode-test/. On an exact-key miss it calls + # the update API before its download retry loop, so an unreachable CDN fails the + # job before any test runs. When the endpoints are down, point the runner at the + # stale binary restored above (runTest.ts honors VSCODE_VERSION) so the suite + # still runs. + - name: Fall back to stale VS Code binary when CDN is unreachable + if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true' && steps.vscode-probe.outputs.reachable == 'false' + id: vscode-fallback + run: | + STALE=$(ls -d apps/vscode-e2e/.vscode-test/vscode-linux-x64-* 2>/dev/null | sort -V | tail -n 1 || true) + if [ -n "$STALE" ]; then + echo "VSCODE_VERSION=${STALE##*-}" >> "$GITHUB_ENV" + echo "used=true" >> "$GITHUB_OUTPUT" + echo "VS Code download endpoints are unreachable; falling back to cached VS Code ${STALE##*-}" + else + echo "::warning::VS Code download endpoints are unreachable and no cached binary is available; the download step will retry but may fail" + fi + + # Retry only the binary download: version resolution and the archive fetch are + # the network-fragile parts. Genuine test failures should fail fast, so the + # test step itself is deliberately not retried. + - name: Download VS Code test binary + if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' + id: vscode-download + run: | + cd apps/vscode-e2e + for attempt in 1 2 3; do + if node -e "const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); const version = process.env.VSCODE_VERSION || require('./package.json').devDependencies['@types/vscode']; downloadAndUnzipVSCode(version).catch((err) => { console.error(err); process.exit(1); });"; then + exit 0 + fi + echo "VS Code download attempt ${attempt} failed" + if [ "$attempt" -lt 3 ]; then + sleep 15 + fi + done + exit 1 + + # restore-keys can bring back older binaries alongside the one just + # downloaded; keep only the effective version so each saved cache entry + # stays at one binary instead of growing with every version bump. + - name: Prune stale VS Code binaries + if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' + run: | + EFFECTIVE="${VSCODE_VERSION:-${{ steps.vscode-ver.outputs.version }}}" + for dir in apps/vscode-e2e/.vscode-test/vscode-linux-x64-*; do + [ -e "$dir" ] || continue + if [ "$(basename "$dir")" != "vscode-linux-x64-$EFFECTIVE" ]; then + echo "Pruning stale VS Code binary $(basename "$dir")" + rm -rf "$dir" + fi + done + + # Skip the save when the stale-binary fallback ran: the pinned-version key + # must not be populated with an older binary. + - name: Save VS Code test binary cache + if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true' && steps.vscode-fallback.outputs.used != 'true' + continue-on-error: true + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | apps/vscode-e2e/.vscode-test/ @@ -59,12 +150,18 @@ jobs: if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true' run: xvfb-run -a pnpm --filter @roo-code/vscode-e2e test:ci:mock + - name: Explain skipped mocked E2E pass marker + if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used == 'true' + run: echo "Skipping mocked E2E pass marker because tests ran against a stale cached VS Code binary (VS Code download endpoints unreachable)." + - name: Write mocked E2E pass marker - if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' + # Skip when the stale-binary fallback ran: a pass against an older VS Code + # must not mint a marker for the intended-version source hash. + if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used != 'true' run: mkdir -p .cache/e2e-pass && date -u > .cache/e2e-pass/passed - name: Save mocked E2E pass marker - if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' + if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used != 'true' continue-on-error: true uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: diff --git a/AGENTS.md b/AGENTS.md index c32cb8c64b..64085dc087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,18 @@ This file provides guidance to agents when working with code in this repository. - Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions. - Changesets: Do NOT create `.changeset` files for each commit or code change. Changesets are managed separately by maintainers and should not be generated by agents during normal development. +## ESLint Suppressions + +`src/eslint-suppressions.json` tracks per-file counts of suppressed lint rules. Suppression counts must never increase. When touching a file, prefer reducing its count when the fix is local and low-risk; avoid broad unrelated cleanup. + +When writing new code: + +- Fix lint violations in the new code rather than suppressing them. +- Avoid `as any`; use typed APIs directly (e.g. `RooCodeEventName.X` constants with typed `on()`/`listenerCount()`), or bracket notation (`obj["privateField"]`) to access private members. Prefer precise test doubles or `unknown` with a type guard over double assertions (`as unknown as T`); use double assertions only as a last resort, with a comment explaining why. +- Avoid floating promises; add `void`, `await`, or `.catch()` as appropriate. +- After editing a file, run `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 ` and confirm the count for that file did not increase. +- If a suppression is truly unavoidable (e.g. `vi.spyOn(Cls.prototype as any, "privateMethod")` where no typed alternative exists), document why in a comment next to the cast. + ## Test Placement Guidance Prefer the narrowest test layer that proves the behavior. This follows standard test-pyramid guidance: keep most coverage in fast, focused tests; add integration tests for cross-module contracts; reserve end-to-end tests for full workflow confidence. diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 890c243b38..f5dfc813df 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -48,6 +48,15 @@ export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed" export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed" export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed" +// Scheduler regression tests — exercises TaskScheduler + run() dispatch post-CodeRabbit fix. +// Separate markers to avoid collisions with the other subtask fixtures. +const SCHED_STANDALONE_MARKER = "SCHED_STANDALONE_INTERRUPT_RESUME" +const SCHED_COMPLETED_MARKER = "SCHED_COMPLETED_REOPEN" +export const SCHED_STANDALONE_PROMPT = `${SCHED_STANDALONE_MARKER}: Ask the user exactly this follow-up question: What is the square root of 64? After the user answers, complete with only the answer.` +export const SCHED_STANDALONE_FOLLOWUP_ANSWER = "8" +export const SCHED_COMPLETED_PROMPT = `${SCHED_COMPLETED_MARKER}: Complete immediately with the exact result "Scheduler completed task".` +export const SCHED_COMPLETED_RESULT = "Scheduler completed task" + const apiHangChildMatch = new RegExp(SUBTASK_API_HANG_CHILD_MARKER) const requestContains = (req: ChatCompletionRequest, expected: string[]) => { @@ -396,6 +405,62 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + // Scheduler regression fixtures: standalone interrupted task resume and completed task reopen. + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SCHED_STANDALONE_MARKER]) && + !requestContains(req, ["call_sched_standalone_followup_001"]) && + !requestContains(req, [`\\n${SCHED_STANDALONE_FOLLOWUP_ANSWER}\\n`]), + }, + response: { + toolCalls: [ + { + name: "ask_followup_question", + arguments: JSON.stringify({ + question: "What is the square root of 64?", + follow_up: [{ text: SCHED_STANDALONE_FOLLOWUP_ANSWER }], + }), + id: "call_sched_standalone_followup_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + toolResultContains(req, "call_sched_standalone_followup_001", [SCHED_STANDALONE_FOLLOWUP_ANSWER]) || + requestContains(req, ["call_sched_standalone_followup_001", SCHED_STANDALONE_FOLLOWUP_ANSWER]) || + requestContains(req, [ + SCHED_STANDALONE_MARKER, + `\\n${SCHED_STANDALONE_FOLLOWUP_ANSWER}\\n`, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SCHED_STANDALONE_FOLLOWUP_ANSWER }), + id: "call_sched_standalone_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { userMessage: new RegExp(SCHED_COMPLETED_MARKER) }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SCHED_COMPLETED_RESULT }), + id: "call_sched_completed_completion_001", + }, + ], + }, + }) + // Interrupted-child-resumes-and-reports-back scenario (#560) mock.addFixture({ match: { diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 641e02f6ac..9e1d0e83cb 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -5,6 +5,10 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "./test-utils" import { sleep, waitFor, waitUntilCompleted } from "./utils" import { + SCHED_COMPLETED_PROMPT, + SCHED_COMPLETED_RESULT, + SCHED_STANDALONE_FOLLOWUP_ANSWER, + SCHED_STANDALONE_PROMPT, SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER, SUBTASK_ABANDON_PARENT_PROMPT, SUBTASK_API_HANG_CHILD_MARKER, @@ -945,4 +949,139 @@ suite("Roo Code Subtasks", function () { await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) } }) + + // TaskScheduler regression: resumeTask on a completed task must show resume_completed_task ask. + // Before the CodeRabbit fix, createTaskWithHistoryItem bypassed the scheduler and called + // Task.run() via the constructor's startTask: true default, causing run() to call startTask() + // (clearing history) instead of resumeTaskFromHistory(). + test("resumeTask on a completed task presents resume_completed_task ask", async () => { + const api = globalThis.api + const asks: Record = {} + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "ask") { + asks[taskId] = asks[taskId] || [] + asks[taskId].push(message) + } + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + try { + // Run a task to completion. + const taskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SCHED_COMPLETED_PROMPT, + }), + }) + + assert.strictEqual( + says[taskId]?.find(({ say }) => say === "completion_result")?.text?.trim(), + SCHED_COMPLETED_RESULT, + "Task should complete with expected result", + ) + + // Re-open it via resumeTask — should hit resumeTaskFromHistory(), showing resume_completed_task. + await api.resumeTask(taskId) + + await waitFor( + () => asks[taskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_completed_task") ?? false, + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(500) + } + }) + + // TaskScheduler regression: resumeTask on an interrupted standalone task must show resume_task + // ask and allow the task to complete normally via the scheduler slot. + test("resumeTask on an interrupted standalone task presents resume_task ask and completes", async () => { + const api = globalThis.api + const asks: Record = {} + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "ask") { + asks[taskId] = asks[taskId] || [] + asks[taskId].push(message) + } + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let taskId: string | undefined + + try { + taskId = await api.startNewTask({ + configuration: { + mode: "ask", + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SCHED_STANDALONE_PROMPT, + }) + + // Wait until the task pauses at the follow-up question. + await waitFor(() => asks[taskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false) + + // Cancel it — the task becomes interrupted. + await api.cancelCurrentTask() + + await waitFor( + () => asks[taskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + ) + + // Resume via scheduler path (createTaskWithHistoryItem). + const askCountBeforeResume = asks[taskId!]?.length ?? 0 + await api.resumeTask(taskId!) + + await waitFor(() => + (asks[taskId!] ?? []) + .slice(askCountBeforeResume) + .some(({ type, ask }) => type === "ask" && ask === "resume_task"), + ) + + // Sending the answer both acknowledges the resume_task ask and answers the pending + // follow-up question from the original task, completing the task. + const completedTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SCHED_STANDALONE_FOLLOWUP_ANSWER) + return taskId! + }, + }) + + assert.strictEqual(completedTaskId, taskId, "The resumed standalone task should complete") + assert.strictEqual( + says[taskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + SCHED_STANDALONE_FOLLOWUP_ANSWER, + "Task should complete with the follow-up answer as result", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(500) + } + }) }) diff --git a/packages/types/package.json b/packages/types/package.json index c4cbd4454c..98cf9f373a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -11,6 +11,14 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./model": { + "types": "./src/model.ts", + "import": "./src/model.ts" + }, + "./provider-identifiers": { + "types": "./src/provider-identifiers.ts", + "import": "./src/provider-identifiers.ts" } }, "scripts": { diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index 724fc20f34..cd786a6529 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -1,107 +1,168 @@ -import { getApiProtocol } from "../provider-settings.js" +import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers } from "../index.js" +import { + getApiProtocol, + OPEN_AI_CODEX_SERVICE_TIER_KEY, + PROVIDER_SETTINGS_KEYS, + providerSettingsSchema, + providerSettingsSchemaDiscriminated, +} from "../provider-settings.js" +import { OpenAiCodexServiceTier, OpenAiServiceTier } from "../model.js" + +describe("OpenAI Codex provider settings", () => { + it("preserves the Fast preference in general and provider-specific schemas", () => { + const settings = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + } + + expect(providerSettingsSchema.parse(settings)).toEqual(settings) + expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings) + expect(PROVIDER_SETTINGS_KEYS).toContain(OPEN_AI_CODEX_SERVICE_TIER_KEY) + }) + + it.each([undefined, OpenAiCodexServiceTier.Default])( + "accepts %s as the Standard preference", + (openAiCodexServiceTier) => { + const standardSettings = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + ...(openAiCodexServiceTier ? { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier } : {}), + } + + expect(providerSettingsSchemaDiscriminated.parse(standardSettings)).toEqual(standardSettings) + }, + ) + + it("rejects unsupported service tiers", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiServiceTier.Flex, + }).success, + ).toBe(false) + }) +}) describe("getApiProtocol", () => { - describe("Anthropic-style providers", () => { - it("should return 'anthropic' for anthropic provider", () => { - expect(getApiProtocol("anthropic")).toBe("anthropic") - expect(getApiProtocol("anthropic", "gpt-4")).toBe("anthropic") - }) + it("preserves API protocol wire values", () => { + expect(ANTHROPIC_API_PROTOCOL).toBe("anthropic") + expect(OPENAI_API_PROTOCOL).toBe("openai") + }) - it("should return 'anthropic' for bedrock provider", () => { - expect(getApiProtocol("bedrock")).toBe("anthropic") - expect(getApiProtocol("bedrock", "gpt-4")).toBe("anthropic") - expect(getApiProtocol("bedrock", "claude-3-opus")).toBe("anthropic") - }) + describe("Anthropic-style providers", () => { + it.each([providerIdentifiers.anthropic, providerIdentifiers.bedrock, providerIdentifiers.minimax])( + "should return 'anthropic' for %s provider", + (provider) => { + expect(getApiProtocol(provider)).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(provider, "gpt-4")).toBe(ANTHROPIC_API_PROTOCOL) + }, + ) }) describe("Vertex provider with Claude models", () => { it("should return 'anthropic' for vertex provider with claude models", () => { - expect(getApiProtocol("vertex", "claude-3-opus")).toBe("anthropic") - expect(getApiProtocol("vertex", "Claude-3-Sonnet")).toBe("anthropic") - expect(getApiProtocol("vertex", "CLAUDE-instant")).toBe("anthropic") - expect(getApiProtocol("vertex", "anthropic/claude-3-haiku")).toBe("anthropic") + expect(getApiProtocol(providerIdentifiers.vertex, "claude-3-opus")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "Claude-3-Sonnet")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "CLAUDE-instant")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "anthropic/claude-3-haiku")).toBe(ANTHROPIC_API_PROTOCOL) }) it("should return 'openai' for vertex provider with non-claude models", () => { - expect(getApiProtocol("vertex", "gpt-4")).toBe("openai") - expect(getApiProtocol("vertex", "gemini-pro")).toBe("openai") - expect(getApiProtocol("vertex", "llama-2")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.vertex, "gpt-4")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "gemini-pro")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "llama-2")).toBe(OPENAI_API_PROTOCOL) }) it("should return 'openai' for vertex provider without model", () => { - expect(getApiProtocol("vertex")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.vertex)).toBe(OPENAI_API_PROTOCOL) }) }) - describe("Vercel AI Gateway provider", () => { + describe("Gateway providers", () => { + it("uses the canonical Zoo Gateway identifier for Anthropic model protocol selection", () => { + expect(getApiProtocol(providerIdentifiers.zooGateway, "anthropic/claude-3-opus")).toBe( + ANTHROPIC_API_PROTOCOL, + ) + }) + it("should return 'anthropic' for vercel-ai-gateway provider with anthropic models", () => { - expect(getApiProtocol("vercel-ai-gateway", "anthropic/claude-3-opus")).toBe("anthropic") - expect(getApiProtocol("vercel-ai-gateway", "anthropic/claude-3.5-sonnet")).toBe("anthropic") - expect(getApiProtocol("vercel-ai-gateway", "ANTHROPIC/claude-sonnet-4")).toBe("anthropic") - expect(getApiProtocol("vercel-ai-gateway", "anthropic/claude-opus-4.1")).toBe("anthropic") + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "anthropic/claude-3-opus")).toBe( + ANTHROPIC_API_PROTOCOL, + ) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "anthropic/claude-3.5-sonnet")).toBe( + ANTHROPIC_API_PROTOCOL, + ) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "ANTHROPIC/claude-sonnet-4")).toBe( + ANTHROPIC_API_PROTOCOL, + ) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "anthropic/claude-opus-4.1")).toBe( + ANTHROPIC_API_PROTOCOL, + ) }) it("should return 'openai' for vercel-ai-gateway provider with non-anthropic models", () => { - expect(getApiProtocol("vercel-ai-gateway", "openai/gpt-4")).toBe("openai") - expect(getApiProtocol("vercel-ai-gateway", "google/gemini-pro")).toBe("openai") - expect(getApiProtocol("vercel-ai-gateway", "meta/llama-3")).toBe("openai") - expect(getApiProtocol("vercel-ai-gateway", "mistral/mixtral")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "openai/gpt-4")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "google/gemini-pro")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "meta/llama-3")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vercelAiGateway, "mistral/mixtral")).toBe(OPENAI_API_PROTOCOL) }) it("should return 'openai' for vercel-ai-gateway provider without model", () => { - expect(getApiProtocol("vercel-ai-gateway")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.vercelAiGateway)).toBe(OPENAI_API_PROTOCOL) }) }) describe("Opencode Go provider", () => { it("should return 'anthropic' for opencode-go Anthropic-format models (Qwen/MiniMax)", () => { - expect(getApiProtocol("opencode-go", "qwen3.7-max")).toBe("anthropic") - expect(getApiProtocol("opencode-go", "qwen3.7-plus")).toBe("anthropic") - expect(getApiProtocol("opencode-go", "qwen3.6-plus")).toBe("anthropic") - expect(getApiProtocol("opencode-go", "minimax-m3")).toBe("anthropic") - expect(getApiProtocol("opencode-go", "minimax-m2.7")).toBe("anthropic") - expect(getApiProtocol("opencode-go", "minimax-m2.5")).toBe("anthropic") + expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.7-max")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.7-plus")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "qwen3.6-plus")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "minimax-m3")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "minimax-m2.7")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "minimax-m2.5")).toBe(ANTHROPIC_API_PROTOCOL) }) it("should return 'openai' for opencode-go OpenAI-format models (GLM/DeepSeek/etc.)", () => { - expect(getApiProtocol("opencode-go", "glm-5.2")).toBe("openai") - expect(getApiProtocol("opencode-go", "deepseek-v4-pro")).toBe("openai") - expect(getApiProtocol("opencode-go", "kimi-k2.5")).toBe("openai") - expect(getApiProtocol("opencode-go", "mimo-v2.5")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.opencodeGo, "glm-5.2")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "deepseek-v4-pro")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "kimi-k2.5")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.opencodeGo, "mimo-v2.5")).toBe(OPENAI_API_PROTOCOL) }) it("should return 'openai' for opencode-go without a model", () => { - expect(getApiProtocol("opencode-go")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.opencodeGo)).toBe(OPENAI_API_PROTOCOL) }) it("should return 'openai' for opencode-go with an unknown model id", () => { - expect(getApiProtocol("opencode-go", "some-future-model")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.opencodeGo, "some-future-model")).toBe(OPENAI_API_PROTOCOL) }) }) describe("Other providers", () => { it("should return 'openai' for non-anthropic providers regardless of model", () => { - expect(getApiProtocol("openrouter", "claude-3-opus")).toBe("openai") - expect(getApiProtocol("openai", "claude-3-sonnet")).toBe("openai") - expect(getApiProtocol("litellm", "claude-instant")).toBe("openai") - expect(getApiProtocol("ollama", "claude-model")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.openrouter, "claude-3-opus")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.openai, "claude-3-sonnet")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.litellm, "claude-instant")).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.ollama, "claude-model")).toBe(OPENAI_API_PROTOCOL) }) }) describe("Edge cases", () => { it("should return 'openai' when provider is undefined", () => { - expect(getApiProtocol(undefined)).toBe("openai") - expect(getApiProtocol(undefined, "claude-3-opus")).toBe("openai") + expect(getApiProtocol(undefined)).toBe(OPENAI_API_PROTOCOL) + expect(getApiProtocol(undefined, "claude-3-opus")).toBe(OPENAI_API_PROTOCOL) }) it("should handle empty strings", () => { - expect(getApiProtocol("vertex", "")).toBe("openai") + expect(getApiProtocol(providerIdentifiers.vertex, "")).toBe(OPENAI_API_PROTOCOL) }) it("should be case-insensitive for claude detection", () => { - expect(getApiProtocol("vertex", "CLAUDE-3-OPUS")).toBe("anthropic") - expect(getApiProtocol("vertex", "claude-3-opus")).toBe("anthropic") - expect(getApiProtocol("vertex", "ClAuDe-InStAnT")).toBe("anthropic") + expect(getApiProtocol(providerIdentifiers.vertex, "CLAUDE-3-OPUS")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "claude-3-opus")).toBe(ANTHROPIC_API_PROTOCOL) + expect(getApiProtocol(providerIdentifiers.vertex, "ClAuDe-InStAnT")).toBe(ANTHROPIC_API_PROTOCOL) }) }) }) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 257a30f3e7..b99ab62360 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -54,13 +54,30 @@ export const verbosityLevelsSchema = z.enum(verbosityLevels) export type VerbosityLevel = z.infer +/** Serialized service tier field used in provider request payloads and responses. */ +export const SERVICE_TIER_KEY = "service_tier" + /** - * Service tiers (OpenAI Responses API) + * Service tiers for the public OpenAI Responses API. */ -export const serviceTiers = ["default", "flex", "priority"] as const +export const OpenAiServiceTier = { + Default: "default", + Flex: "flex", + Priority: "priority", +} as const + +export const serviceTiers = [OpenAiServiceTier.Default, OpenAiServiceTier.Flex, OpenAiServiceTier.Priority] as const export const serviceTierSchema = z.enum(serviceTiers) export type ServiceTier = z.infer +/** + * Service tiers for Codex requests authenticated through a ChatGPT subscription. + */ +export enum OpenAiCodexServiceTier { + Default = "default", + Priority = "priority", +} + /** * ModelParameter */ diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3898c65bcc..6795c9a1b1 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -1,6 +1,12 @@ import { z } from "zod" -import { modelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js" +import { + modelInfoSchema, + OpenAiCodexServiceTier, + reasoningEffortSettingSchema, + verbosityLevelsSchema, + serviceTierSchema, +} from "./model.js" import { codebaseIndexProviderSchema } from "./codebase-index.js" import { providerIdentifiers, @@ -29,6 +35,8 @@ import { minimaxModels, mimoModels, isOpencodeGoAnthropicFormatModel, + ANTHROPIC_API_PROTOCOL, + OPENAI_API_PROTOCOL, } from "./providers/index.js" /** @@ -36,6 +44,7 @@ import { */ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 +export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" /** * DynamicProvider @@ -277,7 +286,10 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({ }) const openAiCodexSchema = apiModelIdProviderModelSchema.extend({ - // No additional settings needed - uses OAuth authentication + // Codex "Fast" mode maps to the Responses API priority service tier. + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: z + .enum([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority]) + .optional(), }) const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ @@ -581,25 +593,42 @@ export const modelIdKeysByProvider: Record = { */ // Providers that use Anthropic-style API protocol. -export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"] +export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = [ + providerIdentifiers.anthropic, + providerIdentifiers.bedrock, + providerIdentifiers.minimax, +] + +const ANTHROPIC_MODEL_GATEWAY_PROVIDERS: ProviderName[] = [ + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, +] + +const ANTHROPIC_MODEL_ID_PREFIX = "anthropic/" +const CLAUDE_MODEL_ID_FRAGMENT = "claude" export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => { if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) { - return "anthropic" + return ANTHROPIC_API_PROTOCOL } - if (provider && provider === "vertex" && modelId && modelId.toLowerCase().includes("claude")) { - return "anthropic" + if ( + provider && + provider === providerIdentifiers.vertex && + modelId && + modelId.toLowerCase().includes(CLAUDE_MODEL_ID_FRAGMENT) + ) { + return ANTHROPIC_API_PROTOCOL } // Vercel AI Gateway and Zoo Gateway use the anthropic protocol for anthropic models. if ( provider && - ["vercel-ai-gateway", "zoo-gateway"].includes(provider) && + ANTHROPIC_MODEL_GATEWAY_PROVIDERS.includes(provider) && modelId && - modelId.toLowerCase().startsWith("anthropic/") + modelId.toLowerCase().startsWith(ANTHROPIC_MODEL_ID_PREFIX) ) { - return "anthropic" + return ANTHROPIC_API_PROTOCOL } // Opencode Go routes a subset of its models (Qwen, MiniMax) through the @@ -609,11 +638,16 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str // models must use the anthropic protocol so token/cost aggregation adds the // cache tokens back into the input total — otherwise the cached prefix is // dropped from `contextTokens`, undercounting context-window usage. - if (provider && provider === "opencode-go" && modelId && isOpencodeGoAnthropicFormatModel(modelId)) { - return "anthropic" + if ( + provider && + provider === providerIdentifiers.opencodeGo && + modelId && + isOpencodeGoAnthropicFormatModel(modelId) + ) { + return ANTHROPIC_API_PROTOCOL } - return "openai" + return OPENAI_API_PROTOCOL } /** diff --git a/packages/types/src/providers/anthropic.ts b/packages/types/src/providers/anthropic.ts index f5f7e71116..971b92211a 100644 --- a/packages/types/src/providers/anthropic.ts +++ b/packages/types/src/providers/anthropic.ts @@ -5,6 +5,7 @@ import type { ModelInfo } from "../model.js" export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5" +export const ANTHROPIC_API_PROTOCOL = "anthropic" export const anthropicModels = { "claude-sonnet-4-6": { diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 71591d8f40..b240e5ca34 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -20,6 +20,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.2 is Zhipu's flagship model with a 1M context window and 128k max output, served via Friendli Model APIs. It delivers top-tier long-context reasoning, coding, and agentic performance for extended engineering sessions.", }, @@ -33,6 +35,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.1 is Zhipu's most capable model with a 200k context window and 128k max output, served via Friendli Model APIs. It delivers top-tier reasoning, coding, and agentic performance.", }, diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index 1601961964..5f114e41f6 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -3,6 +3,7 @@ import type { ModelInfo } from "../model.js" // https://openai.com/api/pricing/ export type OpenAiNativeModelId = keyof typeof openAiNativeModels +export const OPENAI_API_PROTOCOL = "openai" export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5.6-sol" export const openAiNativeModels = { diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0a9c34b979..0154027753 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from "vitest" import type { HistoryItem } from "@roo-code/types" import { RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" +import { TaskScheduler } from "../core/task/TaskScheduler" const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -46,13 +47,14 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const providerEmit = vi.fn() const parentTask = makeParentTask() - const childStart = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) const removeClineFromStack = vi.fn().mockResolvedValue(undefined) - const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }) + const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: childRun }) const handleModeSwitch = vi.fn().mockResolvedValue(undefined) const taskHistoryStore = makeStoreStub() const provider = { + taskScheduler: new TaskScheduler(), emit: providerEmit, getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, @@ -70,6 +72,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], mode: "code", }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked expect(child.taskId).toBe("child-1") @@ -98,8 +101,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { childIds: expect.arrayContaining(["child-1"]), }) - // child.start() called AFTER parent metadata is persisted - expect(childStart).toHaveBeenCalledTimes(1) + // child.run() called AFTER parent metadata is persisted (via taskScheduler) + expect(childRun).toHaveBeenCalledTimes(1) // Provider-level event expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") @@ -117,10 +120,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn() }), + createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }), handleModeSwitch: vi.fn().mockResolvedValue(undefined), postMessageToWebview, log: vi.fn(), @@ -150,10 +154,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn() }), + createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn(), run: () => Promise.resolve() }), handleModeSwitch: vi.fn().mockResolvedValue(undefined), postMessageToWebview, log: vi.fn(), @@ -172,15 +177,15 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(postMessageToWebview).not.toHaveBeenCalled() }) - it("calls child.start() only after atomicReadAndUpdate completes (no race condition)", async () => { + it("calls child.run() only after atomicReadAndUpdate completes (no race condition)", async () => { const callOrder: string[] = [] const parentTask = makeParentTask() - const childStart = vi.fn(() => callOrder.push("child.start")) + const childRun = vi.fn(async () => callOrder.push("child.run")) const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn(async () => { callOrder.push("createTask") - return { taskId: "child-1", start: childStart } + return { taskId: "child-1", start: vi.fn(), run: childRun } }) const handleModeSwitch = vi.fn().mockResolvedValue(undefined) const taskHistoryStore = makeStoreStub({ @@ -191,6 +196,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => parentTask), removeClineFromStack, @@ -208,9 +214,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], mode: "code", }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked - // createTask → atomicReadAndUpdate → child.start: lock must release before start - expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.start"]) + // createTask → atomicReadAndUpdate → child.run: scheduler admits child only after metadata is persisted + expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.run"]) }) it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { @@ -236,10 +243,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask: vi.fn(() => makeParentTask()), removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn() }), + createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn(), run: () => Promise.resolve() }), handleModeSwitch: vi.fn().mockResolvedValue(undefined), log: vi.fn(), isViewLaunched: false, @@ -277,7 +285,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { delegatedToId: oldChildId, } as unknown as HistoryItem - const child = { taskId: "child-2", start: vi.fn() } + const child = { taskId: "child-2", start: vi.fn(), run: vi.fn().mockResolvedValue(undefined) } const getCurrentTask = vi.fn().mockReturnValue(makeParentTask()) const createTask = vi.fn().mockImplementation(async () => { getCurrentTask.mockReturnValue(child) @@ -296,6 +304,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack: vi.fn().mockResolvedValue(undefined), @@ -319,15 +328,15 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), ).rejects.toThrow("Cannot re-delegate") - // Rollback: child must not have started, and must be cleaned up - expect(child.start).not.toHaveBeenCalled() + // Rollback: child must not have run, and must be cleaned up + expect(child.run).not.toHaveBeenCalled() expect((provider as any).deleteTaskWithId).toHaveBeenCalledWith("child-2", false) }) it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => { const persistError = new Error("parent metadata persist failed") const parentTask = makeParentTask() - const childStart = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) @@ -337,7 +346,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError), }) - const child = { taskId: "child-1", start: childStart } + const child = { taskId: "child-1", start: vi.fn(), run: childRun } // Before createTask: getCurrentTask returns parent (used by step 3 close). // After createTask: returns child so the rollback guard passes and the child is popped. const getCurrentTask = vi.fn().mockReturnValue(parentTask) @@ -347,6 +356,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack, @@ -370,7 +380,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), ).rejects.toThrow(persistError) - expect(childStart).not.toHaveBeenCalled() + expect(childRun).not.toHaveBeenCalled() expect(removeClineFromStack).toHaveBeenNthCalledWith(1) expect(removeClineFromStack).toHaveBeenNthCalledWith(2) expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 0cb9425680..9eeec8a960 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { type OutputChannel } from "vscode" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskRegistry } from "../core/task/TaskRegistry" +import { TaskScheduler } from "../core/task/TaskScheduler" import { type Task } from "../core/task/Task" import { API } from "../extension/api" import * as ProfileValidatorMod from "../shared/ProfileValidator" @@ -45,6 +46,9 @@ vi.mock("../core/task/Task", () => { opts.onCreated?.(this) } start() {} + run() { + return Promise.resolve() + } on() {} off() {} emit() {} @@ -63,12 +67,14 @@ describe("Single-open-task invariant", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const addClineToStack = vi.fn().mockResolvedValue(undefined) + const schedulespy = vi.fn().mockResolvedValue(undefined) const existingTask = { taskId: "existing-1", abort: false, abandoned: false } const registry = new TaskRegistry() registry.push(existingTask as unknown as Task) const provider = { taskRegistry: registry, + taskScheduler: { schedule: schedulespy }, getCurrentTask: vi.fn(() => existingTask), taskHistoryStore: { get: vi.fn(() => undefined) }, markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), @@ -104,6 +110,7 @@ describe("Single-open-task invariant", () => { expect(removeClineFromStack).toHaveBeenCalledTimes(1) expect(addClineToStack).toHaveBeenCalledTimes(1) + expect(schedulespy).toHaveBeenCalledTimes(1) }) it("Subtask create: keeps existing task open when parentTask is provided", async () => { @@ -117,6 +124,7 @@ describe("Single-open-task invariant", () => { const provider = { taskRegistry: registry2, + taskScheduler: new TaskScheduler(), setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, @@ -152,6 +160,7 @@ describe("Single-open-task invariant", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const addClineToStack = vi.fn().mockResolvedValue(undefined) const updateGlobalState = vi.fn().mockResolvedValue(undefined) + const schedulespy = vi.fn().mockResolvedValue(undefined) const provider = { getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating @@ -180,6 +189,7 @@ describe("Single-open-task invariant", () => { // Methods used by createTaskWithHistoryItem for pending edit cleanup getPendingEditOperation: vi.fn().mockReturnValue(undefined), clearPendingEditOperation: vi.fn(), + taskScheduler: { schedule: schedulespy }, context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, contextProxy: { extensionUri: {}, @@ -206,6 +216,82 @@ describe("Single-open-task invariant", () => { expect(task).toBeTruthy() expect(removeClineFromStack).toHaveBeenCalledTimes(1) expect(addClineToStack).toHaveBeenCalledTimes(1) + expect(schedulespy).toHaveBeenCalledTimes(1) + }) + + it("History resume path wires scheduler in rehydrating (in-place) case", async () => { + const schedulespy = vi.fn().mockResolvedValue(undefined) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const historyId = "hist-rehydrate-1" + + const existingTask = { + taskId: historyId, + instanceId: "old-inst", + abort: false, + abandoned: false, + abortTask: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + } + + const registry = new TaskRegistry() + registry.push(existingTask as unknown as Task) + + const provider = { + getCurrentTask: vi.fn(() => existingTask), + taskRegistry: registry, + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return privateClineProvider.evictCurrentTask.bind(this) + }, + removeClineFromStack, + addClineToStack: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + providerSettingsManager: { + getModeConfigId: vi.fn().mockResolvedValue(undefined), + listConfig: vi.fn().mockResolvedValue([]), + }, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + enableCheckpoints: true, + checkpointTimeout: 60, + experiments: {}, + cloudUserInfo: null, + taskSyncEnabled: false, + }), + getPendingEditOperation: vi.fn().mockReturnValue(undefined), + clearPendingEditOperation: vi.fn(), + taskScheduler: { schedule: schedulespy }, + taskEventListeners: new WeakMap(), + performPreparationTasks: vi.fn().mockResolvedValue(undefined), + context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, + contextProxy: { + extensionUri: {}, + getValue: vi.fn(), + setValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + + const historyItem = { + id: historyId, + number: 1, + ts: Date.now(), + task: "Task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/tmp", + } + + await privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) + + expect(schedulespy).toHaveBeenCalledTimes(1) + // evictCurrentTask must NOT have been called — in-place replace, no stack pop + expect(removeClineFromStack).not.toHaveBeenCalled() }) it("IPC StartNewTask path closes current before new task", async () => { diff --git a/src/__tests__/task-run-dispatch.spec.ts b/src/__tests__/task-run-dispatch.spec.ts new file mode 100644 index 0000000000..283cec3338 --- /dev/null +++ b/src/__tests__/task-run-dispatch.spec.ts @@ -0,0 +1,82 @@ +// npx vitest run __tests__/task-run-dispatch.spec.ts +// +// Tests Task#run() dispatch logic in isolation by calling the method on a +// minimal stand-in object that mirrors only the private fields run() reads. +// This avoids the cost of constructing a real Task (which pulls in VSCode APIs, +// RooIgnoreController, FileContextTracker, etc.). + +import { describe, it, expect, vi } from "vitest" +import { Task } from "../core/task/Task" + +// Access private fields at runtime — TypeScript enforces visibility at +// compile time only; at runtime they're plain properties on the instance. +type Runnable = { + _started: boolean + _runPromise: Promise | undefined + _isHistoryTask: boolean + metadata: { task?: string | null; images?: string[] | null } + resumeTaskFromHistory: () => Promise + startTask: (task?: string, images?: string[]) => Promise +} + +function makeRunnable(overrides: Partial = {}): Runnable & { run(): Promise } { + const obj: Runnable = { + _started: false, + _runPromise: undefined, + _isHistoryTask: false, + metadata: { task: undefined, images: undefined }, + resumeTaskFromHistory: vi.fn().mockResolvedValue(undefined), + startTask: vi.fn().mockResolvedValue(undefined), + ...overrides, + } + // Bind the real run() implementation from Task.prototype to our stand-in. + const runnable = obj as Runnable & { run(): Promise } + runnable.run = Task.prototype.run.bind(obj) + return runnable +} + +describe("Task#run() dispatch", () => { + it("returns the existing _runPromise when already set", async () => { + const sentinel = Promise.resolve() + const obj = makeRunnable({ _runPromise: sentinel }) + expect(obj.run()).toBe(sentinel) + }) + + it("returns Promise.resolve() and skips dispatch when _started is true", async () => { + const obj = makeRunnable({ _started: true }) + const result = obj.run() + await expect(result).resolves.toBeUndefined() + expect(obj.resumeTaskFromHistory).not.toHaveBeenCalled() + expect(obj.startTask).not.toHaveBeenCalled() + }) + + it("calls resumeTaskFromHistory() when _isHistoryTask is true", async () => { + const obj = makeRunnable({ _isHistoryTask: true }) + await obj.run() + expect(obj.resumeTaskFromHistory).toHaveBeenCalledTimes(1) + expect(obj.startTask).not.toHaveBeenCalled() + }) + + it("calls startTask() when _isHistoryTask is false and task text is set", async () => { + const obj = makeRunnable({ _isHistoryTask: false, metadata: { task: "do something" } }) + await obj.run() + expect(obj.startTask).toHaveBeenCalledWith("do something", undefined) + expect(obj.resumeTaskFromHistory).not.toHaveBeenCalled() + }) + + it("returns Promise.resolve() when _isHistoryTask is false and no task/images", async () => { + const obj = makeRunnable({ _isHistoryTask: false, metadata: { task: undefined, images: undefined } }) + await obj.run() + expect(obj.startTask).not.toHaveBeenCalled() + expect(obj.resumeTaskFromHistory).not.toHaveBeenCalled() + }) + + it("returns the same promise on repeated calls (idempotent via _runPromise)", async () => { + const obj = makeRunnable({ _isHistoryTask: true }) + const p1 = obj.run() + const p2 = obj.run() + expect(p1).toBe(p2) + await p1 + expect(obj.resumeTaskFromHistory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index fc43feae76..67a2b935ec 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,7 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" -import { getVisibleProviderOrLog, registerCommands, setPanel } from "../registerCommands" +import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -13,8 +13,16 @@ vi.mock("vscode", () => ({ QuickFix: { value: "quickfix" }, RefactorRewrite: { value: "refactor.rewrite" }, }, + Uri: { + joinPath: vi.fn((_base: unknown, ..._pathSegments: string[]) => ({ path: _pathSegments.join("/") })), + }, + ViewColumn: { + Two: 2, + }, window: { createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + createWebviewPanel: vi.fn(), + visibleTextEditors: [], }, workspace: { workspaceFolders: [ @@ -367,3 +375,53 @@ describe("registerCommands handlers", () => { await handlers["zoo-code.plusButtonClicked"]() }) }) + +describe("openClineInNewTab", () => { + let mockOutputChannel: vscode.OutputChannel + let mockContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockContext = { + subscriptions: [], + extensionUri: { path: "/mock/ext" }, + } as unknown as vscode.ExtensionContext + + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) + + // Reset module-level panel state. + setPanel(undefined, "sidebar") + setPanel(undefined, "tab") + }) + + it("creates a webview panel with title 'Zoo Code'", async () => { + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + expect.any(Number), + expect.objectContaining({ + enableScripts: true, + retainContextWhenHidden: true, + }), + ) + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 36b7c5d153..692aabfd68 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -251,7 +251,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit { const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("PRIORITY") + expect(commandArg[SERVICE_TIER_KEY]).toBe("PRIORITY") // service_tier should NOT be in additionalModelRequestFields if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined() } }) @@ -1263,10 +1264,10 @@ describe("AwsBedrockHandler", () => { const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("FLEX") + expect(commandArg[SERVICE_TIER_KEY]).toBe("FLEX") // service_tier should NOT be in additionalModelRequestFields if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined() } }) @@ -1294,9 +1295,9 @@ describe("AwsBedrockHandler", () => { const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any // Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() + expect(commandArg[SERVICE_TIER_KEY]).toBeUndefined() if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined() } }) @@ -1323,9 +1324,9 @@ describe("AwsBedrockHandler", () => { const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any // Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() + expect(commandArg[SERVICE_TIER_KEY]).toBeUndefined() if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined() } }) }) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 41892d3bc6..c8fd81ad19 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -142,7 +142,16 @@ describe("FriendliHandler", () => { }, ])( "should expose newly added model $modelId", - ({ modelId, contextWindow, maxTokens, supportsMaxTokens, inputPrice, outputPrice, cacheWritesPrice, cacheReadsPrice }) => { + ({ + modelId, + contextWindow, + maxTokens, + supportsMaxTokens, + inputPrice, + outputPrice, + cacheWritesPrice, + cacheReadsPrice, + }) => { expect(friendliModels[modelId]).toBeDefined() const info = friendliModels[modelId] as import("@roo-code/types").ModelInfo expect(info.maxTokens).toBe(maxTokens) @@ -394,3 +403,217 @@ describe("Friendli model max output tokens (clamping behavior)", () => { expect(result).toBe(80_000) }) }) + +describe("FriendliHandler — Friendli-specific reasoning params", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "zai-org/GLM-5.2", + reasoning_effort: "high", + chat_template_kwargs: { enable_thinking: true }, + parse_reasoning: true, + include_reasoning: true, + }), + undefined, + ) + }) + + it("should send enable_thinking: false when enableReasoningEffort is false on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: false, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is none on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "none", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is disable on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should use model default reasoningEffort when no explicit settings are provided", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + // No enableReasoningEffort or reasoningEffort — model default "high" kicks in + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("high") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) + + it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => { + const handler = new FriendliHandler({ + apiModelId: "deepseek-ai/DeepSeek-V3.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { reasoning_content: "Let me think..." } }], + usage: null, + } + yield { + choices: [{ delta: { content: "The answer is 42" } }], + usage: null, + } + yield { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const stream = handler.createMessage("system", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) + expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) + }) + + it("completePrompt should include reasoning params when enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "medium", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "test result" } }], + }) + + await handler.completePrompt("test") + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("medium") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) +}) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index c6ebd8a6e9..9cbf29cfb6 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -125,7 +125,9 @@ describe("LmStudioHandler", () => { for await (const _chunk of stream) { // Should not reach here } - }).rejects.toThrow("Please check the LM Studio developer logs to debug what went wrong") + }).rejects.toThrow( + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Zoo Code's prompts.", + ) }) }) @@ -144,7 +146,7 @@ describe("LmStudioHandler", () => { it("should handle API errors", async () => { mockCreate.mockRejectedValueOnce(new Error("API Error")) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Please check the LM Studio developer logs to debug what went wrong", + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Zoo Code's prompts.", ) }) diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index c4249f4183..359f62ae2a 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -9,6 +9,7 @@ vitest.mock("@roo-code/telemetry", () => ({ })) import { Anthropic } from "@anthropic-ai/sdk" +import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" import { OpenAiCodexHandler, transformLunaResponsesLiteBody } from "../openai-codex" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" @@ -76,6 +77,84 @@ describe("OpenAiCodexHandler.getModel", () => { }) describe("OpenAiCodexHandler.createMessage", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it("sends the priority service tier in streaming SDK requests when Fast is selected", async () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create: mockCreate } }) + + await drainStream(handler.createMessage("System prompt", [])) + + const [body] = mockCreate.mock.calls[0] + expect(body).toMatchObject({ + stream: true, + [SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + }) + + it.each([ + ["an absent preference", {}], + [ + "an explicit Standard preference from an older profile", + { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Default }, + ], + ])("omits the service tier in streaming SDK requests for %s", async (_description, serviceTierOptions) => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + ...serviceTierOptions, + } as ConstructorParameters[0]) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create: mockCreate } }) + + await drainStream(handler.createMessage("System prompt", [])) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty(SERVICE_TIER_KEY) + }) + + it("preserves the priority service tier in the manual streaming fallback", async () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + Reflect.set(handler, "client", { + responses: { create: vitest.fn().mockRejectedValue(new Error("SDK unavailable")) }, + }) + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ), + ) + controller.close() + }, + }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await drainStream(handler.createMessage("System prompt", [])) + + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({ + stream: true, + [SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + }) + it("should skip URL-sourced images in formatFullConversation (only base64 emits input_image)", async () => { const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.1-codex" }) @@ -176,6 +255,40 @@ describe("OpenAiCodexHandler.createMessage", () => { }) }) +describe("OpenAiCodexHandler.completePrompt service tier", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it.each<[string, OpenAiCodexServiceTier.Priority | undefined, OpenAiCodexServiceTier.Priority | undefined]>([ + ["Fast", OpenAiCodexServiceTier.Priority, OpenAiCodexServiceTier.Priority], + ["Standard", undefined, undefined], + ])("uses the %s preference in non-streaming requests", async (_mode, configuredTier, expectedTier) => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + ...(configuredTier ? { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: configuredTier } : {}), + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + json: vitest.fn().mockResolvedValue({ text: "Complete" }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await expect(handler.completePrompt("Hello")).resolves.toBe("Complete") + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.stream).toBe(false) + if (expectedTier) { + expect(body[SERVICE_TIER_KEY]).toBe(expectedTier) + } else { + expect(body).not.toHaveProperty(SERVICE_TIER_KEY) + } + }) +}) + describe("transformLunaResponsesLiteBody", () => { it("creates the exact Responses Lite body while preserving unrelated fields and reasoning", () => { const tools = [{ type: "function", name: "read_file", parameters: { type: "object" } }] diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index a266642e7a..184f04ec80 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest" import { OpenAiNativeHandler } from "../openai-native" -import { openAiNativeModels } from "@roo-code/types" +import { OpenAiServiceTier, openAiNativeModels } from "@roo-code/types" describe("OpenAiNativeHandler - normalizeUsage", () => { let handler: OpenAiNativeHandler @@ -468,7 +468,7 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { it("should not apply GPT-5.4 long-context pricing to priority tier", () => { handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", - openAiNativeServiceTier: "priority", + openAiNativeServiceTier: OpenAiServiceTier.Priority, }) const usage = { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 1acb4101be..cba0779191 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -13,7 +13,7 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiProviderError } from "@roo-code/types" +import { ApiProviderError, OpenAiServiceTier, SERVICE_TIER_KEY, serviceTiers } from "@roo-code/types" import { OpenAiNativeHandler } from "../openai-native" import { ApiHandlerOptions } from "../../../shared/api" @@ -22,6 +22,24 @@ import { Package } from "../../../shared/package" // Mock OpenAI client - now everything uses Responses API const mockResponsesCreate = vitest.fn() +const serviceTierPricingCases = [ + { + requestedTier: OpenAiServiceTier.Default, + resolvedTier: OpenAiServiceTier.Priority, + expectedCost: 0.00275, + }, + { + requestedTier: OpenAiServiceTier.Priority, + resolvedTier: OpenAiServiceTier.Flex, + expectedCost: 0.00055, + }, + { + requestedTier: OpenAiServiceTier.Flex, + resolvedTier: OpenAiServiceTier.Default, + expectedCost: 0.0011, + }, +] + vitest.mock("openai", () => { return { __esModule: true, @@ -122,6 +140,210 @@ describe("OpenAiNativeHandler", () => { }) describe("createMessage", () => { + it.each(serviceTiers)("should include the selected %s service tier", async (serviceTier) => { + mockResponsesCreate.mockResolvedValue({ + async *[Symbol.asyncIterator]() {}, + }) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + openAiNativeServiceTier: serviceTier, + }) + + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + void chunk + } + + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ [SERVICE_TIER_KEY]: serviceTier }), + expect.any(Object), + ) + }) + + it.each(serviceTierPricingCases)( + "prices SDK stream usage using resolved $resolvedTier tier instead of requested $requestedTier tier", + async ({ requestedTier, resolvedTier, expectedCost }) => { + mockResponsesCreate.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.done", + response: { + [SERVICE_TIER_KEY]: resolvedTier, + usage: { input_tokens: 100, output_tokens: 20 }, + }, + } + }, + }) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + openAiNativeServiceTier: requestedTier, + }) + + const chunks = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual( + expect.objectContaining({ + type: "usage", + inputTokens: 100, + outputTokens: 20, + totalCost: expectedCost, + }), + ) + }, + ) + + it.each([ + { + name: "an explicitly selected default tier", + modelId: "gpt-5.4" as const, + requestedTier: OpenAiServiceTier.Default, + resolvedTier: undefined, + expectedCost: 0.22, + }, + { + name: "no selected service tier", + modelId: "gpt-5.4" as const, + requestedTier: undefined, + resolvedTier: undefined, + expectedCost: 0.22, + }, + { + name: "a resolved service tier without a pricing entry", + modelId: "gpt-5.6-luna" as const, + requestedTier: OpenAiServiceTier.Default, + resolvedTier: OpenAiServiceTier.Priority, + expectedCost: 0.088, + }, + ])("retains standard pricing for $name", async ({ modelId, requestedTier, resolvedTier, expectedCost }) => { + mockResponsesCreate.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.done", + response: { + ...(resolvedTier ? { [SERVICE_TIER_KEY]: resolvedTier } : {}), + usage: { + input_tokens: 100_000, + output_tokens: 1_000, + cache_read_input_tokens: 20_000, + }, + }, + } + }, + }) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: modelId, + openAiNativeServiceTier: requestedTier, + }) + + const chunks = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((chunk) => chunk.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.totalCost).toBeCloseTo(expectedCost, 6) + }) + + it.each(serviceTierPricingCases)( + "requests $requestedTier but prices manual SSE fallback usage using OpenAI's resolved $resolvedTier tier", + async ({ requestedTier, resolvedTier, expectedCost }) => { + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "response.done", + response: { + [SERVICE_TIER_KEY]: resolvedTier, + usage: { input_tokens: 100, output_tokens: 20 }, + }, + })}\n\n`, + ), + ) + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as typeof fetch + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + openAiNativeServiceTier: requestedTier, + }) + + const chunks = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + const [, request] = mockFetch.mock.calls[0] + expect(JSON.parse(request.body)).toMatchObject({ [SERVICE_TIER_KEY]: requestedTier }) + expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", totalCost: expectedCost })) + }, + ) + + it.each(serviceTierPricingCases)( + "captures resolved $resolvedTier tier from a manual SSE completion event when $requestedTier was requested", + async ({ requestedTier, resolvedTier, expectedCost }) => { + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "response.completed", + response: { [SERVICE_TIER_KEY]: resolvedTier }, + })}\n\n`, + ), + ) + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "response.usage", + usage: { input_tokens: 100, output_tokens: 20 }, + })}\n\n`, + ), + ) + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as typeof fetch + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + openAiNativeServiceTier: requestedTier, + }) + + const chunks = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual( + expect.objectContaining({ + type: "usage", + inputTokens: 100, + outputTokens: 20, + totalCost: expectedCost, + }), + ) + }, + ) + it("should handle streaming responses via Responses API", async () => { // Mock fetch for Responses API fallback const mockFetch = vitest.fn().mockResolvedValue({ @@ -221,6 +443,50 @@ describe("OpenAiNativeHandler", () => { ) }) + it.each(serviceTiers)("should include the selected %s service tier", async (serviceTier) => { + mockResponsesCreate.mockResolvedValue({ output: [] }) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + openAiNativeServiceTier: serviceTier, + }) + + await handler.completePrompt("Test prompt") + + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: false, + [SERVICE_TIER_KEY]: serviceTier, + }), + expect.any(Object), + ) + }) + + it("should omit the service tier when none is configured", async () => { + mockResponsesCreate.mockResolvedValue({ output: [] }) + + await handler.completePrompt("Test prompt") + + const [request] = mockResponsesCreate.mock.calls[0] + expect(request.stream).toBe(false) + expect(request).not.toHaveProperty(SERVICE_TIER_KEY) + }) + + it("should omit a configured service tier that the model does not support", async () => { + mockResponsesCreate.mockResolvedValue({ output: [] }) + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-luna", + openAiNativeServiceTier: OpenAiServiceTier.Priority, + }) + + await handler.completePrompt("Test prompt") + + const [request] = mockResponsesCreate.mock.calls[0] + expect(request.stream).toBe(false) + expect(request).not.toHaveProperty(SERVICE_TIER_KEY) + }) + it("should handle SDK errors in completePrompt", async () => { // Mock SDK to throw an error mockResponsesCreate.mockRejectedValue(new Error("API Error")) @@ -332,7 +598,7 @@ describe("OpenAiNativeHandler", () => { expect(modelInfo.info.longContextPricing).toBeUndefined() expect(modelInfo.info.tiers).toEqual([ expect.objectContaining({ - name: "flex", + name: OpenAiServiceTier.Flex, outputPrice: 0.625, }), ]) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 5227c4b289..37fb851720 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -12,14 +12,14 @@ vi.mock("vscode", () => { constructor( public callId: string, public name: string, - public input: any, + public input: object, ) {} } return { workspace: { getConfiguration: vi.fn(() => ({ - get: vi.fn((key: string, defaultValue: any) => defaultValue), + get: vi.fn((key: string, defaultValue: unknown) => defaultValue), })), onDidChangeConfiguration: vi.fn((_callback) => ({ dispose: vi.fn(), @@ -87,6 +87,9 @@ describe("VsCodeLmHandler", () => { beforeEach(() => { vi.clearAllMocks() + // Set up a default successful mock for selectChatModels before creating the handler + const mockModels = [{ ...mockLanguageModelChat }] + ;(vscode.lm.selectChatModels as Mock).mockResolvedValue(mockModels) handler = new VsCodeLmHandler(defaultOptions) }) @@ -106,6 +109,14 @@ describe("VsCodeLmHandler", () => { // Should reset client when config changes expect(handler["client"]).toBeNull() }) + + it("should call initializeClient during construction", () => { + // Constructor calls initializeClient() without await, so it starts async initialization. + // Verify the handler is created and initializeClient was triggered. + expect(handler).toBeDefined() + // The constructor triggers initializeClient which calls selectChatModels + expect(vscode.lm.selectChatModels).toHaveBeenCalled() + }) }) describe("createClient", () => { @@ -135,6 +146,14 @@ describe("VsCodeLmHandler", () => { expect(client.id).toBe("default-lm") expect(client.vendor).toBe("vscode") }) + + it("should throw a Zoo Code branded error when selectChatModels fails", async () => { + ;(vscode.lm.selectChatModels as Mock).mockRejectedValueOnce(new Error("network down")) + + await expect(handler["createClient"]({ vendor: "test" })).rejects.toThrow( + "Zoo Code : Failed to select model: network down", + ) + }) }) describe("createMessage", () => { @@ -397,6 +416,338 @@ describe("VsCodeLmHandler", () => { await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error") }) + + it("should brand the LM authorization justification as Zoo Code", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user" as const, + content: "Hello", + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("Hi") + return + })(), + text: (async function* () { + yield "Hi" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + justification: + "Zoo Code would like to use 'Test Model' from 'test-vendor', Click 'Allow' to proceed.", + }), + expect.anything(), + ) + }) + + it("should throw a Zoo Code branded error when request is cancelled", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user" as const, + content: "Hello", + }, + ] + + mockLanguageModelChat.sendRequest.mockRejectedValueOnce(new vscode.CancellationError()) + + await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow( + "Zoo Code : Request cancelled by user", + ) + }) + + it("should throw a Zoo Code branded error on stream error with error-like object", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user" as const, + content: "Hello", + }, + ] + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + mockLanguageModelChat.sendRequest.mockRejectedValueOnce({ code: "STREAM_ERROR", details: "broken" }) + + await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow( + "Zoo Code : Response stream error:", + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Zoo Code : Stream error object:", + expect.stringContaining("STREAM_ERROR"), + ) + + consoleErrorSpy.mockRestore() + }) + it("should log Zoo Code branded warning for unknown chunk type in stream", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "Hello" }] + + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + // Yield an unknown chunk type (not TextPart, not ToolCallPart) + yield { type: "unknown", foo: "bar" } as unknown as vscode.LanguageModelTextPart + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Unknown chunk type received:", + expect.objectContaining({ type: "unknown" }), + ) + + consoleWarnSpy.mockRestore() + }) + + it("should log Zoo Code branded warning for invalid text part value", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "Hello" }] + + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Create a TextPart with a non-string value (number) + const badTextPart = new vscode.LanguageModelTextPart(42 as unknown as string) + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield badTextPart + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Invalid text part value received:", + 42, + ) + + consoleWarnSpy.mockRestore() + }) + + it("should log Zoo Code branded warning for invalid tool callId", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "Hello" }] + + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Create a ToolCallPart with a non-string callId + const badToolCall = new vscode.LanguageModelToolCallPart(123 as unknown as string, "valid-name", {}) + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield badToolCall + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Invalid tool callId received:", + 123, + ) + + consoleWarnSpy.mockRestore() + }) + + it("should log Zoo Code branded warning for invalid tool input", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "Hello" }] + + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Create a ToolCallPart with a string input (not an object) + const badToolCall = new vscode.LanguageModelToolCallPart( + "call-1", + "valid-name", + "not-an-object" as unknown as object, + ) + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield badToolCall + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Invalid tool input received:", + "not-an-object", + ) + + consoleWarnSpy.mockRestore() + }) + + it("should log Zoo Code branded error when tool call processing fails", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "Hello" }] + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Create a ToolCallPart with circular input that will throw on JSON.stringify + const circularInput: Record = { name: "circular" } + circularInput.self = circularInput + + const badToolCall = new vscode.LanguageModelToolCallPart("call-1", "valid-name", circularInput) + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield badToolCall + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function" as const, + function: { name: "test", description: "", parameters: { type: "object", properties: {} } }, + }, + ], + }) + for await (const _chunk of stream) { + // drain + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Zoo Code : Failed to process tool call:", + expect.any(Error), + ) + + consoleErrorSpy.mockRestore() + }) + }) + + describe("getClient", () => { + it("should log Zoo Code branded debug when creating client with selector", async () => { + const consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + const mockModel = { ...mockLanguageModelChat } + ;(vscode.lm.selectChatModels as Mock).mockResolvedValue([mockModel]) + handler["client"] = null + + // @ts-ignore – access private method for coverage + await handler["getClient"]() + + expect(consoleDebugSpy).toHaveBeenCalledWith( + "Zoo Code : Creating client with selector:", + expect.any(Object), + ) + + consoleDebugSpy.mockRestore() + }) + + it("should throw a Zoo Code branded error when getClient fails to create client", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + ;(vscode.lm.selectChatModels as Mock).mockRejectedValueOnce(new Error("network error")) + handler["client"] = null + + // @ts-ignore – access private method for coverage + await expect(handler["getClient"]()).rejects.toThrow( + "Zoo Code : Failed to create client:", + ) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Zoo Code : Client creation failed:", + expect.stringContaining("network error"), + ) + + consoleErrorSpy.mockRestore() + }) + }) + + describe("initializeClient", () => { + it("should log when client is already initialized", async () => { + const consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + handler["client"] = mockLanguageModelChat + await handler.initializeClient() + + expect(consoleDebugSpy).toHaveBeenCalledWith("Zoo Code : Client already initialized") + + consoleDebugSpy.mockRestore() + }) + + it("should log success when client is initialized", async () => { + const consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + const mockModel = { ...mockLanguageModelChat } + ;(vscode.lm.selectChatModels as Mock).mockResolvedValue([mockModel]) + handler["client"] = null + + await handler.initializeClient() + + expect(consoleDebugSpy).toHaveBeenCalledWith( + "Zoo Code : Client initialized successfully", + ) + + consoleDebugSpy.mockRestore() + }) + + it("should throw a Zoo Code branded error when client initialization fails", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + ;(vscode.lm.selectChatModels as Mock).mockRejectedValue(new Error("select failed")) + handler["client"] = null + + // Catch the unhandled rejection that may occur from the constructor's async call + const initPromise = handler.initializeClient() + + await expect(initPromise).rejects.toThrow("Zoo Code : Failed to initialize client:") + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Zoo Code : Client initialization failed:", + expect.stringContaining("select failed"), + ) + + consoleErrorSpy.mockRestore() + }) }) describe("getModel", () => { @@ -415,11 +766,18 @@ describe("VsCodeLmHandler", () => { }) it("should return fallback model info when no client exists", () => { + const consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + // Clear the client first handler["client"] = null const model = handler.getModel() expect(model.id).toBe("test-vendor/test-family") expect(model.info).toBeDefined() + expect(consoleDebugSpy).toHaveBeenCalledWith( + "Zoo Code : No client available, using fallback model info", + ) + + consoleDebugSpy.mockRestore() }) it("should return basic model info when client exists", async () => { @@ -548,7 +906,7 @@ describe("VsCodeLmHandler", () => { cancel: vi.fn(), dispose: vi.fn(), } - handler["currentRequestCancellation"] = mockCancellation as any + handler["currentRequestCancellation"] = mockCancellation as unknown as vscode.CancellationTokenSource mockLanguageModelChat.countTokens.mockResolvedValueOnce(50) @@ -563,10 +921,17 @@ describe("VsCodeLmHandler", () => { handler["client"] = null handler["currentRequestCancellation"] = null + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello" }] const result = await handler.countTokens(content) expect(result).toBe(0) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : No client available for token counting", + ) + + consoleWarnSpy.mockRestore() }) it("should handle image blocks with placeholder", async () => { @@ -581,6 +946,57 @@ describe("VsCodeLmHandler", () => { expect(result).toBe(5) expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith("[IMAGE]", expect.any(Object)) }) + + it("should return 0 and log when empty text is provided to internalCountTokens", async () => { + handler["currentRequestCancellation"] = null + const consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + // @ts-ignore – access private method for coverage of line 234 + const result = await handler["internalCountTokens"]("") + + expect(result).toBe(0) + expect(consoleDebugSpy).toHaveBeenCalledWith( + "Zoo Code : Empty text provided for token counting", + ) + + consoleDebugSpy.mockRestore() + }) + + it("should return 0 and log when non-numeric token count is received", async () => { + handler["currentRequestCancellation"] = null + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + mockLanguageModelChat.countTokens.mockResolvedValueOnce("not-a-number" as unknown as number) + + const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "test" }] + const result = await handler.countTokens(content) + + expect(result).toBe(0) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Non-numeric token count received:", + "not-a-number", + ) + + consoleWarnSpy.mockRestore() + }) + + it("should return 0 and log when negative token count is received", async () => { + handler["currentRequestCancellation"] = null + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + mockLanguageModelChat.countTokens.mockResolvedValueOnce(-5) + + const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "test" }] + const result = await handler.countTokens(content) + + expect(result).toBe(0) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Negative token count received:", + -5, + ) + + consoleWarnSpy.mockRestore() + }) }) describe("completePrompt", () => { @@ -622,4 +1038,40 @@ describe("VsCodeLmHandler", () => { await expect(promise).rejects.toThrow("VSCode LM completion error: Completion failed") }) }) + + describe("cleanMessageContent / deepClean", () => { + it("passes through string content unchanged", () => { + const result = handler["cleanMessageContent"]("hello") + expect(result).toBe("hello") + }) + + it("returns falsy values as-is", () => { + expect(handler["cleanMessageContent"]("")).toBe("") + }) + + it("recursively cleans array content", () => { + const input: Anthropic.Messages.MessageParam["content"] = [{ type: "text", text: "hi" }] + const result = handler["cleanMessageContent"](input) + expect(result).toEqual([{ type: "text", text: "hi" }]) + }) + + it("recursively cleans nested objects within array items", () => { + const input: Anthropic.Messages.MessageParam["content"] = [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ] + const result = handler["cleanMessageContent"](input) + expect(result).toEqual(input) + }) + + it("preserves primitive values other than strings inside objects", () => { + // deepClean hits the final `return value` branch for non-string primitives + // Exercise via a nested object whose property value is a number + const input = [ + { type: "text", text: "x", extra: 42 }, + ] as unknown as Anthropic.Messages.MessageParam["content"] + const result = handler["cleanMessageContent"](input) as unknown as Array> + expect(result[0].extra).toBe(42) + }) + }) }) diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 645ad8d354..0d39e843c4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -33,6 +33,7 @@ import { BEDROCK_GLOBAL_INFERENCE_MODEL_IDS, BEDROCK_SERVICE_TIER_MODEL_IDS, BEDROCK_SERVICE_TIER_PRICING, + SERVICE_TIER_KEY, ApiProviderError, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -99,7 +100,7 @@ interface BedrockPayload { // AWS Bedrock service tiers (STANDARD, FLEX, PRIORITY) are specified at the top level // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html type BedrockPayloadWithServiceTier = BedrockPayload & { - service_tier?: BedrockServiceTier + [SERVICE_TIER_KEY]?: BedrockServiceTier } // Define specific types for content block events to avoid 'as any' usage @@ -553,7 +554,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }), toolConfig, // Add service_tier as a top-level parameter (not inside additionalModelRequestFields) - ...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }), + ...(useServiceTier && { [SERVICE_TIER_KEY]: this.options.awsBedrockServiceTier }), } // Create AbortController with 10 minute timeout diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index f9aa4fa20c..a5507e355a 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,12 +1,60 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { getModelParams } from "../transform/model-params" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { handleOpenAIError } from "./utils/error-handler" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" + +/** + * Friendli extends the OpenAI Chat Completions API with these non-standard fields: + * - reasoning_effort: enum (minimal, low, medium, high, xhigh, max) — reasoning depth + * - chat_template_kwargs: { enable_thinking: boolean } — toggles thinking for controllable models + * - parse_reasoning / include_reasoning: when true, Friendli streams reasoning via + * delta.reasoning_content (which extractReasoningFromDelta already handles) + * - reasoning_budget: integer token budget (not currently surfaced in settings UI) + * + * The reasoning fields are shared across streaming and non-streaming requests; the base + * `ChatCompletionCreateParams` (non-streaming) variant is used for `completePrompt` while the + * `ChatCompletionCreateParamsStreaming` variant is used for `createStream`. + */ +type FriendliReasoningParams = { + chat_template_kwargs?: { enable_thinking: boolean } + parse_reasoning?: boolean + include_reasoning?: boolean + // Friendli's reasoning_effort supports a broader enum than OpenAI's type allows + reasoning_effort?: + | OpenAI.Chat.Completions.ChatCompletionCreateParams["reasoning_effort"] + | "minimal" + | "xhigh" + | "max" +} + +type FriendliChatCompletionParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + "reasoning_effort" +> & + FriendliReasoningParams + +type FriendliChatCompletionNonStreamingParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParams, + "reasoning_effort" +> & + FriendliReasoningParams /** * Handler for the Friendli Model APIs (OpenAI-compatible). * Routes chat completions to `https://api.friendli.ai/serverless/v1`. + * + * Overrides `createStream` and `completePrompt` to inject Friendli-specific + * reasoning parameters that the base class doesn't know about. */ export class FriendliHandler extends BaseOpenAiCompatibleProvider { /** @@ -23,4 +71,142 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + const { info: modelInfo, reasoningEffort } = this.getModel() + const extra: Partial = {} + + const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) + + const useReasoningEffort = modelInfo.supportsReasoningEffort + ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) + : false + + // User disabled reasoning on a controllable model — explicitly turn thinking off. + // The model's Jinja chat template defaults enable_thinking to true, so omitting + // the param would leave reasoning active (burning tokens against user intent). + if (isControllableReasoning && !useReasoningEffort) { + extra.chat_template_kwargs = { enable_thinking: false } + return extra + } + + // Non-reasoning model — nothing to send + if (!useReasoningEffort) { + return extra + } + + // Reasoning is enabled + extra.parse_reasoning = true + extra.include_reasoning = true + extra.chat_template_kwargs = { enable_thinking: true } + + if (reasoningEffort) { + extra.reasoning_effort = reasoningEffort as FriendliReasoningParams["reasoning_effort"] + } + + return extra + } + + /** + * Override createStream to inject Friendli-specific reasoning params. + * The base class createMessage() calls createStream and handles all stream + * processing (TagMatcher, extractReasoningFromDelta, tool calls, usage). + */ + protected override createStream( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, + ) { + const friendliExtra = this.buildFriendliReasoningParams() + + const { id: model, info } = this.getModel() + + // Centralized cap: clamp to 20% of the context window + const max_tokens = + getModelMaxOutputTokens({ + modelId: model, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + + const params: FriendliChatCompletionParams = { + model, + max_tokens, + temperature, + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...friendliExtra, + } + + try { + return this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } + + override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: modelId } = this.getModel() + const friendliExtra = this.buildFriendliReasoningParams() + + const params: FriendliChatCompletionNonStreamingParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + ...friendliExtra, + } + + try { + const requestOptions: OpenAI.RequestOptions | undefined = + options && (options.abortSignal !== undefined || options.timeoutMs !== undefined) + ? { signal: options.abortSignal, timeout: options.timeoutMs } + : undefined + const response = (await this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions, + )) as OpenAI.Chat.Completions.ChatCompletion + return response.choices?.[0]?.message.content || "" + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 39216418db..79f5355ef8 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -164,7 +164,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } as const } catch (error) { throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Zoo Code's prompts.", ) } } @@ -211,7 +211,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan return response.choices[0]?.message.content || "" } catch (error) { throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Zoo Code's prompts.", ) } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 27ddbe19e2..2619b5114a 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -5,9 +5,12 @@ import OpenAI from "openai" import { type ModelInfo, + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, openAiCodexDefaultModelId, OpenAiCodexModelId, openAiCodexModels, + SERVICE_TIER_KEY, type ReasoningEffort, type ReasoningEffortExtended, ApiProviderError, @@ -29,6 +32,8 @@ import { t } from "../../i18n" export type OpenAiCodexModel = ReturnType +type OpenAiCodexRequestServiceTier = OpenAiCodexServiceTier.Priority + /** * OpenAI Codex base URL for API requests * Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex @@ -37,6 +42,11 @@ const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex" const LUNA_MODEL_ID = "gpt-5.6-luna" const LUNA_CODEX_VERSION = "0.144.0" +const getOpenAiCodexServiceTier = (options: ApiHandlerOptions): OpenAiCodexRequestServiceTier | undefined => + options[OPEN_AI_CODEX_SERVICE_TIER_KEY] === OpenAiCodexServiceTier.Priority + ? OpenAiCodexServiceTier.Priority + : undefined + function stripInputImageDetail(value: any): any { if (Array.isArray(value)) { return value.map(stripInputImageDetail) @@ -365,6 +375,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion model: string input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> stream: boolean + [SERVICE_TIER_KEY]?: OpenAiCodexRequestServiceTier reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } temperature?: number store?: boolean @@ -383,12 +394,14 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Per the implementation guide: Codex backend may reject max_output_tokens // and prompt_cache_retention, so we omit them + const serviceTier = getOpenAiCodexServiceTier(this.options) const body: ResponsesRequestBody = { model: model.id, input: formattedInput, stream: true, store: false, instructions: systemPrompt, + ...(serviceTier ? { [SERVICE_TIER_KEY]: serviceTier } : {}), // Only include encrypted reasoning content when reasoning effort is set ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), ...(reasoningEffort @@ -1261,6 +1274,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } const reasoningEffort = this.getReasoningEffort(model) + const serviceTier = getOpenAiCodexServiceTier(this.options) const baseRequestBody: any = { model: model.id, @@ -1272,6 +1286,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion ], stream: false, store: false, + ...(serviceTier ? { [SERVICE_TIER_KEY]: serviceTier } : {}), ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index a1ce2d89d0..8dffe03dcc 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -13,6 +13,8 @@ import { type ReasoningEffort, type VerbosityLevel, type ReasoningEffortExtended, + OpenAiServiceTier, + SERVICE_TIER_KEY, type ServiceTier, ApiProviderError, } from "@roo-code/types" @@ -318,7 +320,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio max_output_tokens?: number store?: boolean instructions?: string - service_tier?: ServiceTier + [SERVICE_TIER_KEY]?: ServiceTier include?: string[] /** Prompt cache retention policy: "in_memory" (default) or "24h" for extended caching */ prompt_cache_retention?: "in_memory" | "24h" @@ -334,8 +336,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } // Validate requested tier against model support; if not supported, omit. - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) + const serviceTier = this.getAllowedServiceTier(model) // Decide whether to enable extended prompt cache retention for this request const promptCacheRetention = this.getPromptCacheRetention(model) @@ -368,10 +369,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams). ...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}), // Include tier when selected and supported by the model, or when explicitly "default" - ...(requestedTier && - (requestedTier === "default" || allowedTierNames.has(requestedTier)) && { - service_tier: requestedTier, - }), + ...(serviceTier && { [SERVICE_TIER_KEY]: serviceTier }), // Enable extended prompt cache retention for models that support it. // This uses the OpenAI Responses API `prompt_cache_retention` parameter. ...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}), @@ -705,8 +703,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const parsed = JSON.parse(data) // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier + if (parsed.response?.[SERVICE_TIER_KEY]) { + this.lastServiceTier = parsed.response[SERVICE_TIER_KEY] as ServiceTier } // Capture complete output array (includes reasoning items with encrypted_content) if (parsed.response?.output && Array.isArray(parsed.response.output)) { @@ -1015,10 +1013,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio ) } } else if (parsed.type === "response.completed" || parsed.type === "response.done") { - // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier - } // Capture top-level response id if (parsed.response?.id) { this.lastResponseId = parsed.response.id as string @@ -1146,8 +1140,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio */ private async *processEvent(event: any, model: OpenAiNativeModel): ApiStream { // Capture resolved service tier when available - if (event?.response?.service_tier) { - this.lastServiceTier = event.response.service_tier as ServiceTier + if (event?.response?.[SERVICE_TIER_KEY]) { + this.lastServiceTier = event.response[SERVICE_TIER_KEY] as ServiceTier } // Capture complete output array (includes reasoning items with encrypted_content) if (event?.response?.output && Array.isArray(event.response.output)) { @@ -1418,7 +1412,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio * If no tier or no overrides exist, the original ModelInfo is returned. */ private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { - if (!tier || tier === "default") return info + if (!tier || tier === OpenAiServiceTier.Default) return info // Find the tier with matching name in the tiers array const tierInfo = info.tiers?.find((t) => t.name === tier) @@ -1433,6 +1427,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } } + private getAllowedServiceTier(model: OpenAiNativeModel): ServiceTier | undefined { + const requestedTier = this.options.openAiNativeServiceTier + const allowedTierNames = new Set(model.info.tiers?.map(({ name }) => name).filter(Boolean)) + + return requestedTier === OpenAiServiceTier.Default || (requestedTier && allowedTierNames.has(requestedTier)) + ? requestedTier + : undefined + } + // Removed isResponsesApiModel method as ALL models now use the Responses API override getModel() { @@ -1510,10 +1513,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } // Include service tier if selected and supported - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { - requestBody.service_tier = requestedTier + const serviceTier = this.getAllowedServiceTier(model) + if (serviceTier) { + requestBody[SERVICE_TIER_KEY] = serviceTier } // Add reasoning if supported diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 5c88a947de..c657e6c0d6 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -91,7 +91,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan this.dispose() throw new Error( - `Roo Code : Failed to initialize handler: ${error instanceof Error ? error.message : "Unknown error"}`, + `Zoo Code : Failed to initialize handler: ${error instanceof Error ? error.message : "Unknown error"}`, ) } } @@ -106,17 +106,17 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan try { // Check if the client is already initialized if (this.client) { - console.debug("Roo Code : Client already initialized") + console.debug("Zoo Code : Client already initialized") return } // Create a new client instance this.client = await this.createClient(this.options.vsCodeLmModelSelector || {}) - console.debug("Roo Code : Client initialized successfully") + console.debug("Zoo Code : Client initialized successfully") } catch (error) { // Handle errors during client initialization const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.error("Roo Code : Client initialization failed:", errorMessage) - throw new Error(`Roo Code : Failed to initialize client: ${errorMessage}`) + console.error("Zoo Code : Client initialization failed:", errorMessage) + throw new Error(`Zoo Code : Failed to initialize client: ${errorMessage}`) } } /** @@ -164,7 +164,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" - throw new Error(`Roo Code : Failed to select model: ${errorMessage}`) + throw new Error(`Zoo Code : Failed to select model: ${errorMessage}`) } } @@ -225,13 +225,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan private async internalCountTokens(text: string | vscode.LanguageModelChatMessage): Promise { // Check for required dependencies if (!this.client) { - console.warn("Roo Code : No client available for token counting") + console.warn("Zoo Code : No client available for token counting") return 0 } // Validate input if (!text) { - console.debug("Roo Code : Empty text provided for token counting") + console.debug("Zoo Code : Empty text provided for token counting") return 0 } @@ -255,24 +255,24 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } else if (text instanceof vscode.LanguageModelChatMessage) { // For chat messages, ensure we have content if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) { - console.debug("Roo Code : Empty chat message content") + console.debug("Zoo Code : Empty chat message content") return 0 } const countMessage = extractTextCountFromMessage(text) tokenCount = await this.client.countTokens(countMessage, cancellationToken) } else { - console.warn("Roo Code : Invalid input type for token counting") + console.warn("Zoo Code : Invalid input type for token counting") return 0 } // Validate the result if (typeof tokenCount !== "number") { - console.warn("Roo Code : Non-numeric token count received:", tokenCount) + console.warn("Zoo Code : Non-numeric token count received:", tokenCount) return 0 } if (tokenCount < 0) { - console.warn("Roo Code : Negative token count received:", tokenCount) + console.warn("Zoo Code : Negative token count received:", tokenCount) return 0 } @@ -280,12 +280,12 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } catch (error) { // Handle specific error types if (error instanceof vscode.CancellationError) { - console.debug("Roo Code : Token counting cancelled by user") + console.debug("Zoo Code : Token counting cancelled by user") return 0 } const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.warn("Roo Code : Token counting failed:", errorMessage) + console.warn("Zoo Code : Token counting failed:", errorMessage) // Log additional error details if available if (error instanceof Error && error.stack) { @@ -317,7 +317,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan private async getClient(): Promise { if (!this.client) { - console.debug("Roo Code : Getting client with options:", { + console.debug("Zoo Code : Getting client with options:", { vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, hasOptions: !!this.options, selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [], @@ -326,40 +326,46 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan try { // Use default empty selector if none provided to get all available models const selector = this.options?.vsCodeLmModelSelector || {} - console.debug("Roo Code : Creating client with selector:", selector) + console.debug("Zoo Code : Creating client with selector:", selector) this.client = await this.createClient(selector) } catch (error) { const message = error instanceof Error ? error.message : "Unknown error" - console.error("Roo Code : Client creation failed:", message) - throw new Error(`Roo Code : Failed to create client: ${message}`) + console.error("Zoo Code : Client creation failed:", message) + throw new Error(`Zoo Code : Failed to create client: ${message}`) } } return this.client } - private cleanMessageContent(content: any): any { - if (!content) { - return content + private cleanMessageContent( + content: Anthropic.Messages.MessageParam["content"], + ): Anthropic.Messages.MessageParam["content"] { + return this.deepClean(content) as Anthropic.Messages.MessageParam["content"] + } + + private deepClean(value: unknown): unknown { + if (!value) { + return value } - if (typeof content === "string") { - return content + if (typeof value === "string") { + return value } - if (Array.isArray(content)) { - return content.map((item) => this.cleanMessageContent(item)) + if (Array.isArray(value)) { + return value.map((item) => this.deepClean(item)) } - if (typeof content === "object") { - const cleaned: any = {} - for (const [key, value] of Object.entries(content)) { - cleaned[key] = this.cleanMessageContent(value) + if (typeof value === "object") { + const cleaned: Record = {} + for (const [key, v] of Object.entries(value)) { + cleaned[key] = this.deepClean(v) } return cleaned } - return content + return value } override async *createMessage( @@ -395,7 +401,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan try { // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { - justification: `Roo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, + justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, tools: convertToVsCodeLmTools(metadata?.tools ?? []), } @@ -410,7 +416,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan if (chunk instanceof vscode.LanguageModelTextPart) { // Validate text part value if (typeof chunk.value !== "string") { - console.warn("Roo Code : Invalid text part value received:", chunk.value) + console.warn("Zoo Code : Invalid text part value received:", chunk.value) continue } @@ -423,23 +429,23 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan try { // Validate tool call parameters if (!chunk.name || typeof chunk.name !== "string") { - console.warn("Roo Code : Invalid tool name received:", chunk.name) + console.warn("Zoo Code : Invalid tool name received:", chunk.name) continue } if (!chunk.callId || typeof chunk.callId !== "string") { - console.warn("Roo Code : Invalid tool callId received:", chunk.callId) + console.warn("Zoo Code : Invalid tool callId received:", chunk.callId) continue } // Ensure input is a valid object if (!chunk.input || typeof chunk.input !== "object") { - console.warn("Roo Code : Invalid tool input received:", chunk.input) + console.warn("Zoo Code : Invalid tool input received:", chunk.input) continue } // Log tool call for debugging - console.debug("Roo Code : Processing tool call:", { + console.debug("Zoo Code : Processing tool call:", { name: chunk.name, callId: chunk.callId, inputSize: JSON.stringify(chunk.input).length, @@ -457,12 +463,12 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } } catch (error) { - console.error("Roo Code : Failed to process tool call:", error) + console.error("Zoo Code : Failed to process tool call:", error) // Continue processing other chunks even if one fails continue } } else { - console.warn("Roo Code : Unknown chunk type received:", chunk) + console.warn("Zoo Code : Unknown chunk type received:", chunk) } } @@ -479,11 +485,11 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan this.ensureCleanState() if (error instanceof vscode.CancellationError) { - throw new Error("Roo Code : Request cancelled by user") + throw new Error("Zoo Code : Request cancelled by user") } if (error instanceof Error) { - console.error("Roo Code : Stream error details:", { + console.error("Zoo Code : Stream error details:", { message: error.message, stack: error.stack, name: error.name, @@ -494,13 +500,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } else if (typeof error === "object" && error !== null) { // Handle error-like objects const errorDetails = JSON.stringify(error, null, 2) - console.error("Roo Code : Stream error object:", errorDetails) - throw new Error(`Roo Code : Response stream error: ${errorDetails}`) + console.error("Zoo Code : Stream error object:", errorDetails) + throw new Error(`Zoo Code : Response stream error: ${errorDetails}`) } else { // Fallback for unknown error types const errorMessage = String(error) - console.error("Roo Code : Unknown stream error:", errorMessage) - throw new Error(`Roo Code : Response stream error: ${errorMessage}`) + console.error("Zoo Code : Unknown stream error:", errorMessage) + throw new Error(`Zoo Code : Response stream error: ${errorMessage}`) } } } @@ -520,7 +526,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Log any missing properties for debugging for (const [prop, value] of Object.entries(requiredProps)) { if (!value && value !== 0) { - console.warn(`Roo Code : Client missing ${prop} property`) + console.warn(`Zoo Code : Client missing ${prop} property`) } } @@ -551,7 +557,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan ? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector) : "vscode-lm" - console.debug("Roo Code : No client available, using fallback model info") + console.debug("Zoo Code : No client available, using fallback model info") return { id: fallbackId, diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index 36325e9c93..3265f2745b 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -16,11 +16,16 @@ interface MockLanguageModelTextPart { value: string } +type MockLanguageModelChatMessage = { + role: string + content: unknown +} + interface MockLanguageModelToolCallPart { type: "tool_call" callId: string name: string - input: any + input: object } interface MockLanguageModelToolResultPart { @@ -46,7 +51,7 @@ vitest.mock("vscode", () => { constructor( public callId: string, public name: string, - public input: any, + public input: object, ) {} } @@ -154,6 +159,61 @@ describe("convertToVsCodeLmMessages", () => { expect(toolCall.type).toBe("tool_call") }) + it("should handle tool_use with non-object non-string input", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-num", + name: "numericTool", + input: 42 as unknown as object, // number is valid JSON + }, + ], + }, + ] + + const result = convertToVsCodeLmMessages(messages) + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + // asObjectSafe returns {} for non-object/non-string, no console.warn triggered + expect(consoleWarnSpy).not.toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) + + it("should log Zoo Code branded warning when asObjectSafe fails to parse invalid JSON string", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-bad", + name: "badJsonTool", + input: "not-valid-json{{{", + }, + ], + }, + ] + + const result = convertToVsCodeLmMessages(messages) + + expect(result).toHaveLength(1) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Zoo Code : Failed to parse object:", + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + it("should handle image blocks with appropriate placeholders", () => { const messages: Anthropic.Messages.MessageParam[] = [ { @@ -186,7 +246,7 @@ describe("convertToVsCodeLmMessages", () => { content: [ { type: "image", - source: { type: "url", url: "https://example.com/img.png" } as any, + source: { type: "url", url: "https://example.com/img.png" }, }, ], }, @@ -208,7 +268,7 @@ describe("convertToVsCodeLmMessages", () => { content: [ { type: "image", - source: { type: "url", url: "https://example.com/img.png" } as any, + source: { type: "url", url: "https://example.com/img.png" }, }, ], }, @@ -217,7 +277,7 @@ describe("convertToVsCodeLmMessages", () => { ] const result = convertToVsCodeLmMessages(messages) - const toolResult = result[0].content[0] as any + const toolResult = result[0].content[0] as MockLanguageModelToolResultPart expect(toolResult.content[0].value).toContain("[Image (url): not supported by VSCode LM API]") }) @@ -241,7 +301,7 @@ describe("convertToVsCodeLmMessages", () => { ] const result = convertToVsCodeLmMessages(messages) - const toolResult = result[0].content[0] as any + const toolResult = result[0].content[0] as MockLanguageModelToolResultPart expect(toolResult.content[0].value).toBe("[Image (base64): image/jpeg not supported by VSCode LM API]") }) @@ -253,31 +313,31 @@ describe("convertToVsCodeLmMessages", () => { { type: "tool_result", tool_use_id: "tool-1", - content: [{ type: "document" } as any], + content: [{ type: "document" } as unknown as Anthropic.Messages.DocumentBlockParam], }, ], }, ] const result = convertToVsCodeLmMessages(messages) - const toolResult = result[0].content[0] as any + const toolResult = result[0].content[0] as MockLanguageModelToolResultPart expect(toolResult.content[0].value).toBe("") }) }) describe("convertToAnthropicRole", () => { it("should convert assistant role correctly", () => { - const result = convertToAnthropicRole("assistant" as any) + const result = convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant) expect(result).toBe("assistant") }) it("should convert user role correctly", () => { - const result = convertToAnthropicRole("user" as any) + const result = convertToAnthropicRole(vscode.LanguageModelChatMessageRole.User) expect(result).toBe("user") }) it("should return null for unknown roles", () => { - const result = convertToAnthropicRole("unknown" as any) + const result = convertToAnthropicRole("unknown" as unknown as vscode.LanguageModelChatMessageRole) expect(result).toBeNull() }) }) @@ -287,7 +347,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: "Hello world", - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("Hello world") @@ -298,7 +358,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [mockTextPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("Text content") @@ -310,7 +370,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [mockTextPart1, mockTextPart2], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("First partSecond part") @@ -324,7 +384,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [mockToolResultPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("tool-result-idTool result content") @@ -335,7 +395,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "assistant", content: [mockToolCallPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("tool-namecall-id") @@ -351,7 +411,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "assistant", content: [mockToolCallPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe(`calculatorcall-id${JSON.stringify(mockInput)}`) @@ -362,7 +422,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "assistant", content: [mockToolCallPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("tool-namecall-id") @@ -380,7 +440,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "assistant", content: [mockTextPart, mockToolResultPart, mockToolCallPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe(`Text contentresult-idTool resulttoolcall-id${JSON.stringify(mockInput)}`) @@ -390,7 +450,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("") @@ -400,7 +460,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: undefined, - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("") @@ -417,7 +477,7 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [mockToolResultPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("result-idPart 1Part 2") @@ -429,9 +489,39 @@ describe("extractTextCountFromMessage", () => { const message = { role: "user", content: [mockToolResultPart], - } as any + } satisfies MockLanguageModelChatMessage as unknown as vscode.LanguageModelChatMessage const result = extractTextCountFromMessage(message) expect(result).toBe("result-id") }) + + it("should log Zoo Code branded warning when tool call input stringify fails", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Create an object with a circular reference that will throw on JSON.stringify + const circularInput: Record = { name: "circular" } + circularInput.self = circularInput + + const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)( + "call-id", + "broken-tool", + circularInput, + ) + + const message: MockLanguageModelChatMessage = { + role: "assistant", + content: [mockToolCallPart], + } + + const result = extractTextCountFromMessage(message as unknown as vscode.LanguageModelChatMessage) + + // Should still return the tool name and callId even when input stringify fails + expect(result).toBe("broken-toolcall-id") + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Zoo Code : Failed to stringify tool call input:", + expect.any(Error), + ) + + consoleErrorSpy.mockRestore() + }) }) diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index 94716b209d..7ac51e024f 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode" /** * Safely converts a value into a plain object. */ -function asObjectSafe(value: any): object { +function asObjectSafe(value: unknown): object { // Handle null/undefined if (!value) { return {} @@ -23,7 +23,7 @@ function asObjectSafe(value: any): object { return {} } catch (error) { - console.warn("Roo Code : Failed to parse object:", error) + console.warn("Zoo Code : Failed to parse object:", error) return {} } } @@ -197,7 +197,7 @@ export function extractTextCountFromMessage(message: vscode.LanguageModelChatMes try { text += JSON.stringify(item.input) } catch (error) { - console.error("Roo Code : Failed to stringify tool call input:", error) + console.error("Zoo Code : Failed to stringify tool call input:", error) } } } diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index c6bd19c0b1..491ef3b18a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -2,7 +2,12 @@ import { ExtensionContext } from "vscode" -import type { ProviderSettings } from "@roo-code/types" +import { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, + providerIdentifiers, + type ProviderSettings, +} from "@roo-code/types" import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" @@ -451,6 +456,32 @@ describe("ProviderSettingsManager", () => { expect(storedConfig).toEqual(expectedConfig) }) + it.each([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const)( + "should persist the OpenAI Codex %s speed preference", + async (openAiCodexServiceTier) => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: {} }, + modeApiConfigs: {}, + }), + ) + + await providerSettingsManager.saveConfig("codex", { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier, + }) + + const storedProfiles = JSON.parse(mockSecrets.store.mock.calls.at(-1)?.[1]) + expect(storedProfiles.apiConfigs.codex).toMatchObject({ + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier, + }) + }, + ) + it("should only save provider relevant settings", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index da593f0553..8f69a3a0d4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -406,6 +406,8 @@ export class Task extends EventEmitter implements TaskLike { didToolFailInCurrentTurn = false didCompleteReadingStream = false private _started = false + private _runPromise: Promise | undefined + private readonly _isHistoryTask: boolean // No streaming parser is required. assistantMessageParser?: undefined private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void @@ -488,6 +490,7 @@ export class Task extends EventEmitter implements TaskLike { task: historyItem ? historyItem.task : task, images: historyItem ? [] : images, } + this._isHistoryTask = !!historyItem && !task && !images // Normal use-case is usually retry similar history task with new workspace. this.workspacePath = parentTask @@ -1372,7 +1375,7 @@ export class Task extends EventEmitter implements TaskLike { // Wait for askResponse to be set await pWaitFor( () => { - if (this.askResponse !== undefined || this.lastMessageTs !== askTs) { + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { return true } @@ -1397,6 +1400,11 @@ export class Task extends EventEmitter implements TaskLike { { interval: 100 }, ) + /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ + if (this.abort) { + throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) + } + if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with // command_output. It's important that when we know an ask could @@ -1812,9 +1820,13 @@ export class Task extends EventEmitter implements TaskLike { async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { await this.say( "error", - `Roo tried to use ${toolName}${ - relPath ? ` for '${relPath.toPosix()}'` : "" - } without value for required parameter '${paramName}'. Retrying...`, + relPath + ? t("tools:missingToolParameterWithPath", { + toolName, + relPath: relPath.toPosix(), + paramName, + }) + : t("tools:missingToolParameter", { toolName, paramName }), ) return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } @@ -1876,6 +1888,31 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Like `start()`, but returns the underlying promise so callers (e.g. + * `TaskScheduler`) can await task completion and gate concurrency. + * Idempotent: subsequent calls return the same in-flight promise. + */ + public run(): Promise { + if (this._runPromise !== undefined) { + return this._runPromise + } + if (this._started) { + // Already launched via constructor or start() — no promise to return. + return Promise.resolve() + } + this._started = true + + const { task, images } = this.metadata + + this._runPromise = this._isHistoryTask + ? this.resumeTaskFromHistory() + : task || images + ? this.startTask(task ?? undefined, images ?? undefined) + : Promise.resolve() + return this._runPromise + } + private async startTask(task?: string, images?: string[]): Promise { try { // `conversationHistory` (for API) and `clineMessages` (for webview) @@ -1999,7 +2036,7 @@ export class Task extends EventEmitter implements TaskLike { .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. let askType: ClineAsk - if (lastClineMessage?.ask === "completion_result") { + if (this.initialStatus === "completed" || lastClineMessage?.ask === "completion_result") { askType = "resume_completed_task" } else { askType = "resume_task" diff --git a/src/core/task/TaskScheduler.ts b/src/core/task/TaskScheduler.ts new file mode 100644 index 0000000000..7431fb76ad --- /dev/null +++ b/src/core/task/TaskScheduler.ts @@ -0,0 +1,52 @@ +import { TaskSemaphore } from "../../utils/TaskSemaphore" +import { type Task } from "./Task" + +/** + * Semaphore-based concurrency gate for task execution. + * + * Ships at maxConcurrency=1, which is structurally identical to the current + * serial behavior. Raising maxConcurrency later enables Story 3.2b fan-out + * without touching the gate logic here. + */ +export class TaskScheduler { + private readonly sem: TaskSemaphore + + constructor(maxConcurrency = 1) { + this.sem = new TaskSemaphore(maxConcurrency) + } + + get waiting(): number { + return this.sem.waiting + } + + /** + * Acquire a permit for `task`, call `run()`, and release on completion. + * + * The returned promise resolves/rejects with the same value as `run()`. + * Release is guaranteed via try/finally even if `run()` throws. + * + * If the task was aborted or abandoned while waiting for a permit (e.g. the + * user cancelled it before it started), the permit is released immediately + * without calling `run()`. + */ + async schedule(task: Task, run: () => Promise): Promise { + const release = await this.sem.acquire() + if (task.abort || task.abandoned) { + release() + return + } + try { + await run() + } finally { + release() + } + } + + /** + * Cancel all queued (waiting) tasks. Tasks that already acquired a permit + * are not affected — they continue to run to completion. + */ + cancelQueued(): void { + this.sem.cancel() + } +} diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 9d61cd67c1..bc14edb366 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -1,4 +1,4 @@ -import { ProviderSettings } from "@roo-code/types" +import { type ProviderSettings, RooCodeEventName } from "@roo-code/types" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" @@ -44,7 +44,11 @@ vi.mock("@roo-code/telemetry", () => ({ })) describe("Task dispose method", () => { - let mockProvider: any + let mockProvider: { + context: { globalStorageUri: { fsPath: string } } + getState: ReturnType + log: ReturnType + } let mockApiConfiguration: ProviderSettings let task: Task @@ -69,7 +73,7 @@ describe("Task dispose method", () => { // Create task instance without starting it task = new Task({ - provider: mockProvider as ClineProvider, + provider: mockProvider as unknown as ClineProvider, apiConfiguration: mockApiConfiguration, startTask: false, }) @@ -88,15 +92,14 @@ describe("Task dispose method", () => { const listener2 = vi.fn(() => {}) const listener3 = vi.fn((taskId: string) => {}) - // Use type assertion to bypass strict event typing for testing - ;(task as any).on("TaskStarted", listener1) - ;(task as any).on("TaskAborted", listener2) - ;(task as any).on("TaskIdle", listener3) + task.on(RooCodeEventName.TaskStarted, listener1) + task.on(RooCodeEventName.TaskAborted, listener2) + task.on(RooCodeEventName.TaskIdle, listener3) // Verify listeners are added - expect(task.listenerCount("TaskStarted")).toBe(1) - expect(task.listenerCount("TaskAborted")).toBe(1) - expect(task.listenerCount("TaskIdle")).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskStarted)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskAborted)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskIdle)).toBe(1) // Spy on removeAllListeners method const removeAllListenersSpy = vi.spyOn(task, "removeAllListeners") @@ -108,9 +111,9 @@ describe("Task dispose method", () => { expect(removeAllListenersSpy).toHaveBeenCalledOnce() // Verify all listeners are removed - expect(task.listenerCount("TaskStarted")).toBe(0) - expect(task.listenerCount("TaskAborted")).toBe(0) - expect(task.listenerCount("TaskIdle")).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskStarted)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskAborted)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskIdle)).toBe(0) }) test("should handle errors when removing event listeners", () => { @@ -158,53 +161,125 @@ describe("Task dispose method", () => { const listeners = { TaskStarted: vi.fn(() => {}), TaskAborted: vi.fn(() => {}), - TaskIdle: vi.fn((taskId: string) => {}), - TaskActive: vi.fn((taskId: string) => {}), + TaskIdle: vi.fn((_taskId: string) => {}), + TaskActive: vi.fn((_taskId: string) => {}), TaskAskResponded: vi.fn(() => {}), - Message: vi.fn((data: { action: "created" | "updated"; message: any }) => {}), - TaskTokenUsageUpdated: vi.fn((taskId: string, tokenUsage: any) => {}), - TaskToolFailed: vi.fn((taskId: string, tool: any, error: string) => {}), - TaskUnpaused: vi.fn(() => {}), + Message: vi.fn(() => {}), + TaskTokenUsageUpdated: vi.fn(() => {}), + TaskToolFailed: vi.fn(() => {}), + TaskUnpaused: vi.fn((_taskId: string) => {}), } - // Add all listeners using type assertion to bypass strict typing for testing - const taskAny = task as any - taskAny.on("TaskStarted", listeners.TaskStarted) - taskAny.on("TaskAborted", listeners.TaskAborted) - taskAny.on("TaskIdle", listeners.TaskIdle) - taskAny.on("TaskActive", listeners.TaskActive) - taskAny.on("TaskAskResponded", listeners.TaskAskResponded) - taskAny.on("Message", listeners.Message) - taskAny.on("TaskTokenUsageUpdated", listeners.TaskTokenUsageUpdated) - taskAny.on("TaskToolFailed", listeners.TaskToolFailed) - taskAny.on("TaskUnpaused", listeners.TaskUnpaused) + task.on(RooCodeEventName.TaskStarted, listeners.TaskStarted) + task.on(RooCodeEventName.TaskAborted, listeners.TaskAborted) + task.on(RooCodeEventName.TaskIdle, listeners.TaskIdle) + task.on(RooCodeEventName.TaskActive, listeners.TaskActive) + task.on(RooCodeEventName.TaskAskResponded, listeners.TaskAskResponded) + task.on(RooCodeEventName.Message, listeners.Message) + task.on(RooCodeEventName.TaskTokenUsageUpdated, listeners.TaskTokenUsageUpdated) + task.on(RooCodeEventName.TaskToolFailed, listeners.TaskToolFailed) + task.on(RooCodeEventName.TaskUnpaused, listeners.TaskUnpaused) // Verify all listeners are added - expect(task.listenerCount("TaskStarted")).toBe(1) - expect(task.listenerCount("TaskAborted")).toBe(1) - expect(task.listenerCount("TaskIdle")).toBe(1) - expect(task.listenerCount("TaskActive")).toBe(1) - expect(task.listenerCount("TaskAskResponded")).toBe(1) - expect(task.listenerCount("Message")).toBe(1) - expect(task.listenerCount("TaskTokenUsageUpdated")).toBe(1) - expect(task.listenerCount("TaskToolFailed")).toBe(1) - expect(task.listenerCount("TaskUnpaused")).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskStarted)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskAborted)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskIdle)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskActive)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskAskResponded)).toBe(1) + expect(task.listenerCount(RooCodeEventName.Message)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskTokenUsageUpdated)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskToolFailed)).toBe(1) + expect(task.listenerCount(RooCodeEventName.TaskUnpaused)).toBe(1) // Call dispose task.dispose() // Verify all listeners are removed - expect(task.listenerCount("TaskStarted")).toBe(0) - expect(task.listenerCount("TaskAborted")).toBe(0) - expect(task.listenerCount("TaskIdle")).toBe(0) - expect(task.listenerCount("TaskActive")).toBe(0) - expect(task.listenerCount("TaskAskResponded")).toBe(0) - expect(task.listenerCount("Message")).toBe(0) - expect(task.listenerCount("TaskTokenUsageUpdated")).toBe(0) - expect(task.listenerCount("TaskToolFailed")).toBe(0) - expect(task.listenerCount("TaskUnpaused")).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskStarted)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskAborted)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskIdle)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskActive)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskAskResponded)).toBe(0) + expect(task.listenerCount(RooCodeEventName.Message)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskTokenUsageUpdated)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskToolFailed)).toBe(0) + expect(task.listenerCount(RooCodeEventName.TaskUnpaused)).toBe(0) // Verify total listener count is 0 expect(task.eventNames().length).toBe(0) }) }) + +describe("Task.run() idempotency", () => { + // Reuses the mock setup from the outer describe block above. + let mockProvider: ReturnType + let mockApiConfiguration: ProviderSettings + + function buildMockProvider() { + return { + context: { globalStorageUri: { fsPath: "/test/path" } }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + } + } + + beforeEach(() => { + vi.clearAllMocks() + mockProvider = buildMockProvider() + mockApiConfiguration = { apiProvider: "anthropic", apiKey: "test-key" } as ProviderSettings + }) + + test("run() does not invoke startTask when task was already started by constructor", async () => { + // Spy on the prototype before construction so we capture the constructor's call too. + const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockResolvedValue(undefined) + + const t = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "hello", + startTask: true, + }) + + const callsBefore = startTaskSpy.mock.calls.length // constructor fired it once + void t.run() + expect(startTaskSpy.mock.calls.length).toBe(callsBefore) // run() must not add a second call + t.dispose() + startTaskSpy.mockRestore() + }) + + test("run() does not invoke startTask when task was already started by start()", async () => { + const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockResolvedValue(undefined) + + const t = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "hello", + startTask: false, + }) + t.start() + const callsAfterStart = startTaskSpy.mock.calls.length // start() fired it once + + void t.run() + expect(startTaskSpy.mock.calls.length).toBe(callsAfterStart) // no additional call + t.dispose() + startTaskSpy.mockRestore() + }) + + test("run() returns the same promise on repeated calls", async () => { + const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockResolvedValue(undefined) + + const t = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "hello", + startTask: false, + }) + + const p1 = t.run() + const p2 = t.run() + expect(p1).toBe(p2) + await p1 + t.dispose() + startTaskSpy.mockRestore() + }) +}) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0b4201d135..d9f7240c5c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -187,6 +187,20 @@ vi.mock("../../environment/getEnvironmentDetails", () => ({ vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../../i18n", () => { + return { + t: (key: string, args?: Record) => { + if (key === "tools:missingToolParameterWithPath") { + return `${args?.toolName}|${args?.relPath}|${args?.paramName}` + } + if (key === "tools:missingToolParameter") { + return `${args?.toolName}|${args?.paramName}` + } + return key + }, + } +}) + vi.mock("../../condense", async (importOriginal) => { const actual = await importOriginal() return { @@ -427,6 +441,42 @@ describe("Cline", () => { }) }) + describe("sayAndCreateMissingParamError", () => { + it("surfaces a localized error notice and returns the missing-parameter tool error for both relPath branches", async () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(cline, "say").mockResolvedValue(undefined) + + // relPath provided -> the "...WithPath" message branch. + const withPath = await cline.sayAndCreateMissingParamError("read_file", "path", "src/foo.ts") + // relPath omitted -> the plain message branch. + const withoutPath = await cline.sayAndCreateMissingParamError("execute_command", "command") + + // Both branches emit an "error" say whose resolved text names the tool and the + // missing parameter (guards against a silent i18n regression where t() would + // otherwise return the raw key or an empty string and still type-check as a String). + expect(saySpy).toHaveBeenCalledTimes(2) + const [withPathChannel, withPathNotice] = saySpy.mock.calls[0] + const [withoutPathChannel, withoutPathNotice] = saySpy.mock.calls[1] + expect(withPathChannel).toBe("error") + expect(withoutPathChannel).toBe("error") + expect(withPathNotice).toEqual(expect.stringContaining("read_file")) + expect(withPathNotice).toEqual(expect.stringContaining("path")) + expect(withPathNotice).toEqual(expect.stringContaining("src/foo.ts")) + expect(withoutPathNotice).toEqual(expect.stringContaining("execute_command")) + expect(withoutPathNotice).toEqual(expect.stringContaining("command")) + + // The returned tool error names the missing parameter. + expect(withPath).toContain("path") + expect(withoutPath).toContain("command") + }) + }) + describe("getEnvironmentDetails", () => { describe("API conversation handling", () => { it("should strip non-protocol fields from API conversation history before sending to the API", async () => { diff --git a/src/core/task/__tests__/TaskScheduler.spec.ts b/src/core/task/__tests__/TaskScheduler.spec.ts new file mode 100644 index 0000000000..9a0a4f5701 --- /dev/null +++ b/src/core/task/__tests__/TaskScheduler.spec.ts @@ -0,0 +1,161 @@ +import { TaskScheduler } from "../TaskScheduler" +import { type Task } from "../Task" + +const stubTask = () => ({}) as unknown as Task + +describe("TaskScheduler", () => { + it("runs a task immediately when a permit is available", async () => { + const scheduler = new TaskScheduler(1) + let ran = false + await scheduler.schedule(stubTask(), async () => { + ran = true + }) + expect(ran).toBe(true) + }) + + it("queues a second task at maxConcurrency=1 until the first completes", async () => { + const scheduler = new TaskScheduler(1) + const order: number[] = [] + let resolveFirst!: () => void + + const first = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveFirst = res))) + // Give the microtask queue a chance to acquire the permit. + await Promise.resolve() + + expect(scheduler.waiting).toBe(0) + + const second = scheduler.schedule(stubTask(), async () => { + order.push(2) + }) + await Promise.resolve() + expect(scheduler.waiting).toBe(1) + + order.push(1) + resolveFirst() + await first + await second + + expect(order).toEqual([1, 2]) + }) + + it("allows two tasks in parallel at maxConcurrency=2", async () => { + const scheduler = new TaskScheduler(2) + const running: number[] = [] + let resolveA: (() => void) | undefined + let resolveB: (() => void) | undefined + + const a = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveA = res))) + const b = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveB = res))) + + // Two microtask ticks: one for sem.acquire() in each schedule() call. + await Promise.resolve() + await Promise.resolve() + + expect(scheduler.waiting).toBe(0) + expect(resolveA).toBeDefined() + expect(resolveB).toBeDefined() + running.push(1, 2) + resolveA!() + resolveB!() + await Promise.all([a, b]) + expect(running).toEqual([1, 2]) + }) + + it("releases the permit even when the run function throws", async () => { + const scheduler = new TaskScheduler(1) + await expect( + scheduler.schedule(stubTask(), async () => { + throw new Error("boom") + }), + ).rejects.toThrow("boom") + + // Permit must have been released — next task should run immediately. + let ran = false + await scheduler.schedule(stubTask(), async () => { + ran = true + }) + expect(ran).toBe(true) + }) + + it("cancelQueued() rejects waiting tasks without affecting the running one", async () => { + const scheduler = new TaskScheduler(1) + let resolveRunning!: () => void + const running = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveRunning = res))) + + await Promise.resolve() + + const errors: unknown[] = [] + const queued = scheduler.schedule(stubTask(), async () => {}).catch((e) => errors.push(e)) + await Promise.resolve() + + expect(scheduler.waiting).toBe(1) + scheduler.cancelQueued() + expect(scheduler.waiting).toBe(0) + + await queued + expect(errors).toHaveLength(1) + + // Running task is unaffected. + resolveRunning() + await expect(running).resolves.toBeUndefined() + }) + + it("skips run() and releases permit when task is aborted before admission", async () => { + const scheduler = new TaskScheduler(1) + let resolveFirst!: () => void + const first = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveFirst = res))) + await Promise.resolve() + + const abortedTask = { abort: true, abandoned: false } as unknown as Task + let ran = false + const queued = scheduler.schedule(abortedTask, async () => { + ran = true + }) + await Promise.resolve() + expect(scheduler.waiting).toBe(1) + + resolveFirst() + await Promise.all([first, queued]) + + expect(ran).toBe(false) + // Permit must be released — a subsequent task can run immediately. + let next = false + await scheduler.schedule(stubTask(), async () => { + next = true + }) + expect(next).toBe(true) + }) + + it("skips run() and releases permit when task is abandoned before admission", async () => { + const scheduler = new TaskScheduler(1) + let resolveFirst!: () => void + const first = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveFirst = res))) + await Promise.resolve() + + const abandonedTask = { abort: false, abandoned: true } as unknown as Task + let ran = false + const queued = scheduler.schedule(abandonedTask, async () => { + ran = true + }) + await Promise.resolve() + + resolveFirst() + await Promise.all([first, queued]) + + expect(ran).toBe(false) + }) + + it("defaults to maxConcurrency=1", async () => { + const scheduler = new TaskScheduler() + let resolveFirst!: () => void + const first = scheduler.schedule(stubTask(), () => new Promise((res) => (resolveFirst = res))) + await Promise.resolve() + + const second = scheduler.schedule(stubTask(), async () => {}) + await Promise.resolve() + expect(scheduler.waiting).toBe(1) + + resolveFirst() + await Promise.all([first, second]) + }) +}) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 3d6a44ef18..6e8a9becb4 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -35,6 +35,15 @@ export function canRetryShellIntegrationError(error: unknown): error is ShellInt return error instanceof ShellIntegrationError && !error.commandSubmitted } +/** + * Grace period before a foreground command may trigger a `command_output` ask. + * Short commands that emit output and exit within this window never prompt the + * user; the ask only fires when the command is still running once the delay + * elapses, so users can still interrupt or provide feedback on long-running + * commands. + */ +export const COMMAND_OUTPUT_ASK_DELAY_MS = 5_000 + export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): { terminalProvider: RooTerminalProvider isCmdExeFallback: boolean @@ -340,6 +349,58 @@ export async function executeCommandInTerminal( resolveOnCompleted = resolve }) + // Delay the `command_output` ask so short foreground commands that emit + // output and exit normally never prompt the user. The ask only fires if the + // command is still running once COMMAND_OUTPUT_ASK_DELAY_MS has elapsed + // since execution started, preserving the interrupt/feedback path for + // long-running commands. The anchor is re-based to onShellExecutionStarted + // (falling back to the pre-runCommand timestamp when that event never + // fires) so shell-integration startup on cold terminals does not consume + // the grace period. + let commandStartedAt = 0 + let commandOutputAskTimer: NodeJS.Timeout | undefined + + const askForCommandOutput = async (process: RooTerminalProcess): Promise => { + if (runInBackground || hasAskedForCommandOutput || completed) { + return + } + + // Mark that we've asked to prevent multiple concurrent asks + hasAskedForCommandOutput = true + + try { + const { response, text, images } = await task.ask("command_output", "") + runInBackground = true + + if (response === "messageResponse") { + message = { text, images } + } + + // Any answer means the command should keep running in the background; + // continue the process so the tool resolves now instead of blocking + // until the command actually completes. + process.continue() + } catch (_error) { + // Silently handle ask errors (e.g., "Current ask promise was ignored") + } + } + + const scheduleCommandOutputAsk = (process: RooTerminalProcess): void => { + if (runInBackground || hasAskedForCommandOutput || completed || commandOutputAskTimer) { + return + } + + const remainingDelay = COMMAND_OUTPUT_ASK_DELAY_MS - (Date.now() - commandStartedAt) + + commandOutputAskTimer = setTimeout( + () => { + commandOutputAskTimer = undefined + void askForCommandOutput(process) + }, + Math.max(remainingDelay, 0), + ) + } + const callbacks: RooTerminalCallbacks = { onLine: async (lines: string, process: RooTerminalProcess) => { accumulatedOutput += lines @@ -359,26 +420,19 @@ export async function executeCommandInTerminal( provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) schedulePartialCommandOutputUpdate() - if (runInBackground || hasAskedForCommandOutput) { - return - } - - // Mark that we've asked to prevent multiple concurrent asks - hasAskedForCommandOutput = true - - try { - const { response, text, images } = await task.ask("command_output", "") - runInBackground = true - - if (response === "messageResponse") { - message = { text, images } - process.continue() - } - } catch (_error) { - // Silently handle ask errors (e.g., "Current ask promise was ignored") - } + scheduleCommandOutputAsk(process) }, onCompleted: async (output: string | undefined) => { + clearTimeout(commandOutputAskTimer) + commandOutputAskTimer = undefined + + // If an interactive command_output ask is still pending, supersede it + // so it resolves immediately instead of lingering until the next + // interactive message bumps lastMessageTs. + if (hasAskedForCommandOutput && !runInBackground) { + task.supersedePendingAsk() + } + clearTimeout(pendingCommandOutputEmitTimer) pendingCommandOutputEmitTimer = undefined @@ -412,9 +466,21 @@ export async function executeCommandInTerminal( console.error("[ExecuteCommandTool] Failed to flush final command_output:", error) }) }, - onShellExecutionStarted: (pid: number | undefined) => { + onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => { const status: CommandExecutionStatus = { executionId, status: "started", pid, command } provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + + // Re-anchor the ask delay to actual execution start so the shell + // integration startup wait does not count against the grace period. + commandStartedAt = Date.now() + + // Output should not precede this event, but if it did, reschedule + // the pending ask against the corrected anchor. + if (commandOutputAskTimer) { + clearTimeout(commandOutputAskTimer) + commandOutputAskTimer = undefined + scheduleCommandOutputAsk(process) + } }, onShellExecutionComplete: (details: ExitCodeDetails) => { const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode } @@ -441,6 +507,8 @@ export async function executeCommandInTerminal( workingDir = terminal.getCurrentWorkingDirectory() } + // Fallback anchor for providers that never fire onShellExecutionStarted. + commandStartedAt = Date.now() const process = terminal.runCommand(command, callbacks) task.terminalProcess = process @@ -462,6 +530,8 @@ export async function executeCommandInTerminal( new Promise((resolve) => { agentTimeoutId = setTimeout(() => { runInBackground = true + clearTimeout(commandOutputAskTimer) + commandOutputAskTimer = undefined process.continue() task.supersedePendingAsk() resolve() @@ -501,6 +571,7 @@ export async function executeCommandInTerminal( } finally { clearTimeout(agentTimeoutId) clearTimeout(userTimeoutId) + clearTimeout(commandOutputAskTimer) clearTimeout(pendingCommandOutputEmitTimer) task.terminalProcess = undefined } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 1725154328..c3236f3565 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -8,6 +8,7 @@ import { formatResponse } from "../../prompts/responses" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { Terminal } from "../../../integrations/terminal/Terminal" +import type { RooTerminalCallbacks, RooTerminalProcess } from "../../../integrations/terminal/types" // Mock dependencies vitest.mock("execa", () => ({ @@ -77,6 +78,7 @@ describe("executeCommandTool", () => { }, recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage), recordToolError: vitest.fn(), + supersedePendingAsk: vitest.fn(), providerRef: { deref: vitest.fn().mockResolvedValue({ getState: vitest.fn().mockResolvedValue({ @@ -333,4 +335,338 @@ describe("executeCommandTool", () => { expect(executeCommandModule.resolveAgentTimeoutMs(30)).toBe(30_000) }) }) + + describe("command_output ask policy", () => { + type MockProcess = Promise & { + continue: ReturnType + abort: ReturnType + } + + interface ControllableTerminal { + callbacks: RooTerminalCallbacks | undefined + proc: MockProcess + resolveProcess: () => void + } + + const setupControllableTerminal = async (): Promise => { + const { TerminalRegistry } = await import("../../../integrations/terminal/TerminalRegistry") + const state: ControllableTerminal = { + callbacks: undefined, + proc: undefined as unknown as MockProcess, + resolveProcess: () => {}, + } + const processPromise = new Promise((resolve) => { + state.resolveProcess = resolve + }) + // Mirror real terminal behavior: continue() resolves the wait early + // while the command keeps running in the background. + state.proc = Object.assign(processPromise, { + continue: vitest.fn(() => state.resolveProcess()), + abort: vitest.fn(), + }) + ;(TerminalRegistry.getOrCreateTerminal as ReturnType).mockResolvedValue({ + runCommand: vitest.fn((_cmd: string, callbacks: RooTerminalCallbacks) => { + state.callbacks = callbacks + return state.proc + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + }) + return state + } + + const handleCommand = (command: string, timeout?: number) => { + mockToolUse.params.command = command + mockToolUse.params.timeout = timeout === undefined ? undefined : String(timeout) + mockToolUse.nativeArgs = timeout === undefined ? { command } : { command, timeout } + + return executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + } + + it("does not ask about command output when a short command emits output and exits normally", async () => { + vitest.useFakeTimers() + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("echo hello") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("hello\n", proc) + await callbacks.onCompleted!("hello\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + + // Advance past the ask delay to prove the scheduled ask was cancelled + // on completion, not merely deferred beyond the test's runtime. + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000) + + await handlePromise + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("hello") + expect(result).toContain("Exit code: 0") + }) + + it("asks about command output when the command is still running after the ask delay", async () => { + vitest.useFakeTimers() + mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined }) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 60") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("working...\n", proc) + + // First output alone must not trigger the ask. + expect(mockCline.ask).not.toHaveBeenCalled() + + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS) + + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + expect(terminal.proc.continue).toHaveBeenCalled() + + // Further output after the ask must not schedule another ask. + await callbacks.onLine("still working...\n", proc) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + + // Let the command finish so the tool can resolve. + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("anchors the ask delay to execution start so shell integration startup does not consume it", async () => { + vitest.useFakeTimers() + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("echo hello") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + // Simulate a cold terminal spending most of the grace period waiting + // for shell integration before the command actually starts. + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS - 2_000) + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("hello\n", proc) + + // Past the pre-runCommand anchor deadline but well within the window + // measured from execution start: still no ask. + await vitest.advanceTimersByTimeAsync(2_500) + expect(mockCline.ask).not.toHaveBeenCalled() + + await callbacks.onCompleted!("hello\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000) + + await handlePromise + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("re-anchors a pending ask when execution start is reported after early output", async () => { + vitest.useFakeTimers() + mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined }) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 60") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + // Output arrives before the execution-started event (defensive case). + await callbacks.onLine("working...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS - 2_000) + callbacks.onShellExecutionStarted!(1234, proc) + + // The pending ask was rescheduled against the new anchor, so the old + // deadline passing must not fire it. + await vitest.advanceTimersByTimeAsync(2_500) + expect(mockCline.ask).not.toHaveBeenCalled() + + // The ask must still fire at the re-anchored deadline — a version + // that cleared the old timer without rescheduling would fail here. + await vitest.advanceTimersByTimeAsync(2_500) + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + + // Let the command finish so the tool can resolve. + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + await vitest.advanceTimersByTimeAsync(100) + + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("cancels a pending ask when the agent timeout moves the command to the background", async () => { + vitest.useFakeTimers() + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("npm run dev", 2) + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("server starting...\n", proc) + + // Agent timeout (2s) fires before the ask delay (5s). + await vitest.advanceTimersByTimeAsync(2_000) + expect(terminal.proc.continue).toHaveBeenCalled() + expect(mockCline.supersedePendingAsk).toHaveBeenCalled() + + // Output after the background transition must never schedule an ask. + await callbacks.onLine("listening...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000) + expect(mockCline.ask).not.toHaveBeenCalled() + + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") + }) + + it("falls back to the command dispatch time when execution start is never reported", async () => { + vitest.useFakeTimers() + mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined }) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 60") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + // No onShellExecutionStarted: the pre-runCommand anchor applies. + await callbacks.onLine("working...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS) + + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("swallows ask errors without failing the command", async () => { + vitest.useFakeTimers() + mockCline.ask.mockRejectedValue(new Error("Current ask promise was ignored")) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 60") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("working...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS) + + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + expect(terminal.proc.continue).not.toHaveBeenCalled() + + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0") + }) + + it("resolves before completion when the ask is answered without a message", async () => { + // Note: in production only messageResponse answers reach a + // command_output ask (Proceed/Kill route through terminalOperation); + // yesButtonClicked is synthetic here to pin the non-message branch. + vitest.useFakeTimers() + mockCline.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 60") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("working...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS) + + // Any ask answer backgrounds the command: the process is continued and + // the tool resolves without waiting for the command to complete. + // Note the process promise is never resolved in this test. + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + expect(terminal.proc.continue).toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") + + // Cleanup: let the command finish. + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + }) + + it("supersedes a pending ask when the command completes", async () => { + vitest.useFakeTimers() + mockCline.ask.mockReturnValue(new Promise(() => {})) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("sleep 5") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("working...\n", proc) + await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS) + + expect(mockCline.ask).toHaveBeenCalledWith("command_output", "") + + // The command completes while the ask is still pending. + await callbacks.onCompleted!("working...\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + + await handlePromise + + expect(mockCline.supersedePendingAsk).toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0") + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 458120d9a4..2ee92edebc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -54,6 +54,7 @@ import { } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" +import { TaskScheduler } from "../task/TaskScheduler" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" @@ -144,7 +145,7 @@ function runDelegationTransition( locks.set(parentTaskId, tail) - tail.finally(() => { + void tail.finally(() => { if (locks.get(parentTaskId) === tail) { locks.delete(parentTaskId) } @@ -153,6 +154,12 @@ function runDelegationTransition( return current } +function scheduleTask(scheduler: TaskScheduler, task: Task, source: string): void { + void scheduler + .schedule(task, () => task.run()) + .catch((error) => console.error(`[${source}] taskScheduler.schedule failed:`, error)) +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -167,6 +174,7 @@ export class ClineProvider private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel private taskRegistry = new TaskRegistry() + private taskScheduler = new TaskScheduler() private delegationTransitionLocks?: Map> private cancelledDelegationChildIds = new Set() private codeIndexStatusSubscription?: vscode.Disposable @@ -228,7 +236,7 @@ export class ClineProvider ClineProvider.activeInstances.add(this) this.mdmService = mdmService - this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) + void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) // Initialize the per-task file-based history store. // The globalState write-through is debounced separately (not on every mutation) @@ -683,6 +691,10 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") + // Reject any tasks still waiting for a scheduler permit so they don't + // hold the event loop after the provider is torn down. + this.taskScheduler.cancelQueued() + // Clear all tasks from the stack. The first pop goes through evictCurrentTask() // so an active delegated child is marked interrupted before the extension shuts down, // rather than being left persisted as "active" across the reload. @@ -725,7 +737,7 @@ export class ClineProvider this.mcpHub = undefined await this.skillsManager?.dispose() this.skillsManager = undefined - this.marketplaceManager?.cleanup() + await this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.flushGlobalStateWriteThrough() @@ -860,9 +872,27 @@ export class ClineProvider setPanel(webviewView, "sidebar") } + // Set up webview options with proper resource roots + const resourceRoots = [this.contextProxy.extensionUri] + + // Add workspace folders to allow access to workspace files + if (vscode.workspace.workspaceFolders) { + resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri)) + } + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: resourceRoots, + } + + webviewView.webview.html = + this.contextProxy.extensionMode === vscode.ExtensionMode.Development + ? await this.getHMRHtmlContent(webviewView.webview) + : await this.getHtmlContent(webviewView.webview) + // Initialize out-of-scope variables that need to receive persistent // global state values. - this.getState().then( + await this.getState().then( ({ terminalShellIntegrationTimeout = Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled = false, @@ -890,24 +920,6 @@ export class ClineProvider }, ) - // Set up webview options with proper resource roots - const resourceRoots = [this.contextProxy.extensionUri] - - // Add workspace folders to allow access to workspace files - if (vscode.workspace.workspaceFolders) { - resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri)) - } - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: resourceRoots, - } - - webviewView.webview.html = - this.contextProxy.extensionMode === vscode.ExtensionMode.Development - ? await this.getHMRHtmlContent(webviewView.webview) - : await this.getHtmlContent(webviewView.webview) - // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. this.setWebviewMessageListener(webviewView.webview) @@ -930,7 +942,7 @@ export class ClineProvider // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { if (this.view?.visible) { - this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } @@ -941,7 +953,7 @@ export class ClineProvider // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { if (this.view?.visible) { - this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } @@ -1182,7 +1194,7 @@ export class ClineProvider taskNumber: historyItem.number, workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, - startTask: options?.startTask ?? true, + startTask: false, // Preserve the status from the history item to avoid overwriting it when the task saves messages initialStatus: historyItem.status, rateLimitClock: this.rateLimitClock, @@ -1222,12 +1234,20 @@ export class ClineProvider this.log( `[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`, ) + + if (options?.startTask !== false) { + scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem") + } } else { await this.addClineToStack(task) this.log( `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) + + if (options?.startTask !== false) { + scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem") + } } // Check if there's a pending edit after checkpoint restoration @@ -1379,7 +1399,7 @@ export class ClineProvider window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" - Roo Code + Zoo Code
@@ -1458,7 +1478,7 @@ export class ClineProvider window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" - Roo Code + Zoo Code @@ -2139,7 +2159,7 @@ export class ClineProvider const state = await this.getStateToPostToWebview() this.clineMessagesSeq++ state.clineMessagesSeq = this.clineMessagesSeq - this.postMessageToWebview({ type: "state", state }) + await this.postMessageToWebview({ type: "state", state }) } /** @@ -2155,7 +2175,7 @@ export class ClineProvider this.clineMessagesSeq++ state.clineMessagesSeq = this.clineMessagesSeq const { taskHistory: _omit, ...rest } = state - this.postMessageToWebview({ type: "state", state: rest }) + await this.postMessageToWebview({ type: "state", state: rest }) } /** @@ -2172,7 +2192,7 @@ export class ClineProvider async postStateToWebviewWithoutClineMessages(): Promise { const state = await this.getStateToPostToWebview() const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state - this.postMessageToWebview({ type: "state", state: rest }) + await this.postMessageToWebview({ type: "state", state: rest }) } /** @@ -2192,7 +2212,7 @@ export class ClineProvider ]) // Send marketplace data separately - this.postMessageToWebview({ + await this.postMessageToWebview({ type: "marketplaceData", organizationMcps: marketplaceResult.organizationMcps || [], marketplaceItems: marketplaceResult.marketplaceItems || [], @@ -2203,7 +2223,7 @@ export class ClineProvider console.error("Failed to fetch marketplace data:", error) // Send empty data on error to prevent UI from hanging - this.postMessageToWebview({ + await this.postMessageToWebview({ type: "marketplaceData", organizationMcps: [], marketplaceItems: [], @@ -3002,7 +3022,7 @@ export class ClineProvider if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { // Get the full status from the manager to ensure we have all fields correctly formatted const fullStatus = currentManager.getCurrentStatus() - this.postMessageToWebview({ + void this.postMessageToWebview({ type: "indexingStatusUpdate", values: fullStatus, }) @@ -3014,7 +3034,7 @@ export class ClineProvider } // Send initial status for the current workspace - this.postMessageToWebview({ + void this.postMessageToWebview({ type: "indexingStatusUpdate", values: currentManager.getCurrentStatus(), }) @@ -3186,7 +3206,7 @@ export class ClineProvider await this.addClineToStack(task) if (options.startTask !== false) { - task.start() + scheduleTask(this.taskScheduler, task, "createTask") } this.log( @@ -3698,7 +3718,7 @@ export class ClineProvider } // 6) Start the child task now that parent metadata is safely persisted. - child.start() + scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") // 7) Emit TaskDelegated (provider-level) try { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93f4f2afaa..b99e502f61 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -12,6 +12,7 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, @@ -28,6 +29,7 @@ import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" +import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -169,6 +171,8 @@ vi.mock("vscode", () => ({ showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), + showSaveDialog: vi.fn(), + showOpenDialog: vi.fn(), activeTextEditor: undefined, onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), }, @@ -202,8 +206,39 @@ vi.mock("vscode", () => ({ })) vi.mock("../../../utils/tts", () => ({ + playTts: vi.fn().mockResolvedValue(undefined), setTtsEnabled: vi.fn(), setTtsSpeed: vi.fn(), + stopTts: vi.fn(), +})) + +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../../integrations/misc/image-handler", () => ({ + openImage: vi.fn().mockResolvedValue(undefined), + saveImage: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../mentions", () => ({ + openMention: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../../utils/export", () => ({ + resolveDefaultSaveUri: vi.fn().mockResolvedValue({ fsPath: "/test/default-export.yaml" }), + saveLastExportPath: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../../integrations/openai-codex/oauth", () => ({ + openAiCodexOAuthManager: { + getAccessToken: vi.fn(), + getAccountId: vi.fn(), + }, +})) + +vi.mock("../../../integrations/openai-codex/rate-limits", () => ({ + fetchOpenAiCodexRateLimitInfo: vi.fn(), })) vi.mock("../../../api", () => ({ @@ -219,7 +254,7 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { return { default: vi.fn().mockImplementation(function () { return { - initializeFilePaths: vi.fn(), + initializeFilePaths: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), } }), @@ -359,6 +394,8 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + login: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), off: vi.fn(), } }, @@ -577,6 +614,7 @@ describe("ClineProvider", () => { }) expect(mockWebviewView.webview.html).toContain("") + expect(mockWebviewView.webview.html).toContain("Zoo Code") }) describe("logWebviewHiddenDiagnostics", () => { @@ -785,7 +823,8 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) // Get the message handler from onDidReceiveMessage - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock + .calls[0][0] // Simulate webviewDidLaunch message await messageHandler({ type: "webviewDidLaunch" }) @@ -794,6 +833,29 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + test("logs detached workspace initialization failures", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let rejectInitialization!: (error: Error) => void + const initializationPromise = new Promise((_, reject) => { + rejectInitialization = reject + }) + const initializeSpy = vi + .spyOn(provider.workspaceTracker!, "initializeFilePaths") + .mockReturnValue(initializationPromise) + const logSpy = vi.spyOn(provider, "log") + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + await expect(messageHandler({ type: "webviewDidLaunch" })).resolves.toBeUndefined() + expect(initializeSpy).toHaveBeenCalledOnce() + + rejectInitialization(new Error("workspace boom")) + await Promise.resolve() + await Promise.resolve() + + expect(logSpy).toHaveBeenCalledWith("Workspace initialization error: Error: workspace boom") + }) + test("clearTask aborts current task", async () => { // Setup Cline instance with auto-mock from the top of the file const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance @@ -1183,7 +1245,7 @@ describe("ClineProvider", () => { setModeConfig: vi.fn(), } as any - provider.setValue("currentApiConfigName", "current-config") + await provider.setValue("currentApiConfigName", "current-config") // Switch to architect mode await messageHandler({ type: "mode", text: "architect" }) @@ -1281,7 +1343,7 @@ describe("ClineProvider", () => { }, } - provider.setValue("customModePrompts", existingPrompts) + await provider.setValue("customModePrompts", existingPrompts) // Test updating a prompt await messageHandler({ @@ -2273,6 +2335,397 @@ describe("ClineProvider", () => { }) }) +describe("webviewMessageHandler no-floating-promises coverage", () => { + const createProvider = (overrides: Record = {}) => + Object.assign( + { + context: { + secrets: { + get: vi.fn().mockResolvedValue(undefined), + }, + }, + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn().mockResolvedValue(undefined), + }, + postMessageToWebview: vi.fn().mockResolvedValue(true), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + getCurrentTask: vi.fn(), + getCurrentWorkspaceCodeIndexManager: vi.fn(), + getMcpHub: vi.fn().mockReturnValue({ + getMcpSettingsFilePath: vi.fn().mockResolvedValue("/test/mcp.json"), + }), + providerSettingsManager: { + listConfig: vi.fn().mockResolvedValue([]), + }, + customModesManager: { + getCustomModesFilePath: vi.fn().mockResolvedValue("/test/custom-modes.yaml"), + exportModeWithRules: vi.fn(), + importModeWithRules: vi.fn(), + getCustomModes: vi.fn().mockResolvedValue([]), + checkRulesDirectoryHasContent: vi.fn().mockResolvedValue(true), + }, + exportTaskWithId: vi.fn().mockResolvedValue(undefined), + showTaskWithId: vi.fn().mockResolvedValue(undefined), + condenseTaskContext: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + cwd: "/test/workspace", + }, + overrides, + ) as unknown as ClineProvider + + const createIndexManager = (overrides: Record = {}) => + Object.assign( + { + setWorkspaceEnabled: vi.fn().mockResolvedValue(undefined), + setAutoEnableDefault: vi.fn().mockResolvedValue(undefined), + isFeatureEnabled: true, + isFeatureConfigured: true, + isWorkspaceEnabled: true, + initialize: vi.fn().mockResolvedValue(undefined), + state: "Standby", + isInitialized: true, + startIndexing: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn(), + clearIndexData: vi.fn().mockResolvedValue(undefined), + getCurrentStatus: vi.fn().mockReturnValue({ systemStatus: "Standby" }), + }, + overrides, + ) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("logs a detached indexing rejection without rejecting the handler", async () => { + let rejectIndexing!: (error: Error) => void + const indexingPromise = new Promise((_, reject) => { + rejectIndexing = reject + }) + const manager = createIndexManager({ + startIndexing: vi.fn().mockReturnValue(indexingPromise), + }) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + }) + + await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() + expect(manager.startIndexing).toHaveBeenCalledOnce() + + rejectIndexing(new Error("boom")) + await Promise.resolve() + await Promise.resolve() + + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: boom") + }) + + it("covers the changed task-operation happy paths", async () => { + const task = { + taskId: "task-1", + handleTerminalOperation: vi.fn().mockResolvedValue(undefined), + } + const provider = createProvider({ getCurrentTask: vi.fn().mockReturnValue(task) }) + + await webviewMessageHandler(provider, { type: "terminalOperation", terminalOperation: "continue" }) + await webviewMessageHandler(provider, { type: "exportCurrentTask" }) + await webviewMessageHandler(provider, { type: "showTaskWithId", text: "task-2" }) + await webviewMessageHandler(provider, { type: "condenseTaskContextRequest", text: "task-2" }) + await webviewMessageHandler(provider, { type: "deleteTaskWithId", text: "task-2" }) + await webviewMessageHandler(provider, { type: "exportTaskWithId", text: "task-2" }) + + expect(task.handleTerminalOperation).toHaveBeenCalledWith("continue") + expect(provider.exportTaskWithId).toHaveBeenCalledTimes(2) + expect(provider.showTaskWithId).toHaveBeenCalledWith("task-2") + expect(provider.condenseTaskContext).toHaveBeenCalledWith("task-2") + expect(provider.deleteTaskWithId).toHaveBeenCalledWith("task-2") + }) + + it("covers changed file, image, mention, settings, and TTS dispatch paths", async () => { + const { openFile } = await import("../../../integrations/misc/open-file") + const { openImage, saveImage } = await import("../../../integrations/misc/image-handler") + const { openMention } = await import("../../mentions") + const { playTts } = await import("../../../utils/tts") + const provider = createProvider() + + await webviewMessageHandler(provider, { type: "openImage", text: "/test/image.png" }) + await webviewMessageHandler(provider, { type: "saveImage", dataUri: "invalid" }) + await webviewMessageHandler(provider, { type: "openFile", text: "/test/file.ts" }) + await webviewMessageHandler(provider, { type: "openMention", text: "file.ts" }) + await webviewMessageHandler(provider, { type: "openCustomModesSettings" }) + await webviewMessageHandler(provider, { type: "openMcpSettings" }) + await webviewMessageHandler(provider, { type: "playTts", text: "hello" }) + + expect(openImage).toHaveBeenCalledWith("/test/image.png", { values: undefined }) + expect(saveImage).toHaveBeenCalledOnce() + expect(openFile).toHaveBeenCalledTimes(3) + expect(openMention).toHaveBeenCalledWith("/test/workspace", "file.ts") + expect(playTts).toHaveBeenCalledOnce() + }) + + it("covers changed configuration and rules response paths", async () => { + const provider = createProvider() + + await webviewMessageHandler(provider, { type: "getListApiConfiguration" }) + await webviewMessageHandler(provider, { type: "checkRulesDirectory", slug: "mode-1" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ type: "listApiConfig", listApiConfig: [] }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "checkRulesDirectoryResult", + slug: "mode-1", + hasContent: true, + }) + }) + + it("covers all changed export-mode response paths", async () => { + const provider = createProvider() + const exportModeWithRules = provider.customModesManager.exportModeWithRules as ReturnType + const showSaveDialog = vi.mocked(vscode.window.showSaveDialog) + + exportModeWithRules.mockResolvedValueOnce({ success: true, yaml: "mode: one" }) + showSaveDialog.mockResolvedValueOnce({ fsPath: "/test/mode.yaml" } as vscode.Uri) + await webviewMessageHandler(provider, { type: "exportMode", slug: "mode-1" }) + + exportModeWithRules.mockResolvedValueOnce({ success: true, yaml: "mode: one" }) + showSaveDialog.mockResolvedValueOnce(undefined) + await webviewMessageHandler(provider, { type: "exportMode", slug: "mode-1" }) + + exportModeWithRules.mockResolvedValueOnce({ success: false, error: "invalid mode" }) + await webviewMessageHandler(provider, { type: "exportMode", slug: "mode-1" }) + + exportModeWithRules.mockRejectedValueOnce(new Error("export failed")) + await webviewMessageHandler(provider, { type: "exportMode", slug: "mode-1" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "exportModeResult", success: true }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "exportModeResult", error: "Export cancelled" }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "exportModeResult", error: "invalid mode" }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "exportModeResult", error: "export failed" }), + ) + }) + + it("covers all changed import-mode response paths", async () => { + const provider = createProvider() + const importModeWithRules = provider.customModesManager.importModeWithRules as ReturnType + const showOpenDialog = vi.mocked(vscode.window.showOpenDialog) + const selectedFile = [{ fsPath: "/test/mode.yaml" } as vscode.Uri] + + showOpenDialog.mockResolvedValueOnce(selectedFile) + importModeWithRules.mockResolvedValueOnce({ success: true, slug: "mode-1" }) + await webviewMessageHandler(provider, { type: "importMode", source: "project" }) + + showOpenDialog.mockResolvedValueOnce(selectedFile) + importModeWithRules.mockResolvedValueOnce({ success: false, error: "invalid mode" }) + await webviewMessageHandler(provider, { type: "importMode", source: "project" }) + + showOpenDialog.mockResolvedValueOnce(undefined) + await webviewMessageHandler(provider, { type: "importMode", source: "project" }) + + showOpenDialog.mockRejectedValueOnce(new Error("dialog failed")) + await webviewMessageHandler(provider, { type: "importMode", source: "project" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "importModeResult", success: true }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "importModeResult", error: "invalid mode" }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "importModeResult", error: "cancelled" }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "importModeResult", error: "dialog failed" }), + ) + }) + + it("covers changed cloud sign-out and rate-limit error responses", async () => { + const { CloudService } = await import("@roo-code/cloud") + const { openAiCodexOAuthManager } = await import("../../../integrations/openai-codex/oauth") + const provider = createProvider() + + vi.mocked(CloudService.hasInstance).mockReturnValueOnce(false) + await webviewMessageHandler(provider, { type: "rooCloudSignOut" }) + await webviewMessageHandler(provider, { type: "rooCloudSignOut" }) + + vi.mocked(openAiCodexOAuthManager.getAccessToken).mockRejectedValueOnce(new Error("token failed")) + await webviewMessageHandler(provider, { type: "requestOpenAiCodexRateLimits" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "openAiCodexRateLimits", + error: "token failed", + }) + }) + + it("covers changed indexing status, secret, and missing-manager responses", async () => { + const manager = createIndexManager() + const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) + const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + + await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) + await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) + await webviewMessageHandler(provider, { type: "requestCodeIndexSecretStatus" }) + getManager.mockReturnValueOnce(undefined) + await webviewMessageHandler(provider, { type: "startIndexing" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "codeIndexSecretStatus" }), + ) + expect(provider.log).toHaveBeenCalledWith("Cannot start indexing: No workspace folder open") + }) + + it("catches both start-indexing calls during error recovery", async () => { + const manager = createIndexManager({ + isInitialized: false, + startIndexing: vi + .fn() + .mockRejectedValueOnce(new Error("first failure")) + .mockRejectedValueOnce(new Error("second failure")), + }) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + }) + + await webviewMessageHandler(provider, { type: "startIndexing" }) + await Promise.resolve() + + expect(manager.startIndexing).toHaveBeenCalledTimes(2) + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: first failure") + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: second failure") + }) + + it("covers changed stop, toggle, and detached toggle rejection paths", async () => { + const manager = createIndexManager({ + startIndexing: vi.fn().mockRejectedValue(new Error("toggle failure")), + }) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + }) + + await webviewMessageHandler(provider, { type: "stopIndexing" }) + await webviewMessageHandler(provider, { type: "toggleWorkspaceIndexing", bool: true }) + await Promise.resolve() + + expect(manager.stopIndexing).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: toggle failure") + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "indexingStatusUpdate" }), + ) + }) + + it("catches auto-enabled indexing failures and posts the resulting status", async () => { + const { CodeIndexManager } = await import("../../../services/code-index/manager") + let workspaceEnabled = false + const manager = createIndexManager({ + setAutoEnableDefault: vi.fn().mockImplementation(async () => { + workspaceEnabled = true + }), + startIndexing: vi.fn().mockRejectedValue(new Error("auto-enable failure")), + }) + Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => workspaceEnabled }) + const getAllInstances = vi + .spyOn(CodeIndexManager, "getAllInstances") + .mockReturnValue([manager] as unknown as ReturnType) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + }) + + try { + await webviewMessageHandler(provider, { type: "setAutoEnableDefault", bool: true }) + await Promise.resolve() + + expect(manager.startIndexing).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: auto-enable failure") + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "indexingStatusUpdate" }), + ) + } finally { + getAllInstances.mockRestore() + } + }) + + it("covers changed clear-index response paths", async () => { + const manager = createIndexManager() + const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) + const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + + await webviewMessageHandler(provider, { type: "clearIndexData" }) + await webviewMessageHandler(provider, { type: "clearIndexData" }) + manager.clearIndexData.mockRejectedValueOnce(new Error("clear failed")) + await webviewMessageHandler(provider, { type: "clearIndexData" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexCleared", + values: { success: true }, + }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexCleared", + values: { success: false, error: "clear failed" }, + }) + }) + + it("covers changed marketplace error and removal responses", async () => { + const provider = createProvider() + const item = { + id: "item-1", + name: "Item 1", + description: "Test marketplace item", + type: "mode", + content: "slug: item-1", + } satisfies NonNullable + const options = { target: "project" } satisfies NonNullable + const marketplaceManager = { + installMarketplaceItem: vi.fn().mockRejectedValue(new Error("install failed")), + removeInstalledMarketplaceItem: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("remove failed")), + } + const managerArgument = marketplaceManager as unknown as NonNullable< + Parameters[2] + > + + await webviewMessageHandler( + provider, + { type: "installMarketplaceItem", mpItem: item, mpInstallOptions: options }, + managerArgument, + ) + await webviewMessageHandler( + provider, + { type: "removeInstalledMarketplaceItem", mpItem: item, mpInstallOptions: options }, + managerArgument, + ) + await webviewMessageHandler( + provider, + { type: "removeInstalledMarketplaceItem", mpItem: item, mpInstallOptions: options }, + managerArgument, + ) + await webviewMessageHandler(provider, { + type: "removeInstalledMarketplaceItem", + mpItem: item, + mpInstallOptions: options, + }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "marketplaceInstallResult", success: false }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "marketplaceRemoveResult", success: true }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "marketplaceRemoveResult", error: "remove failed" }), + ) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "marketplaceRemoveResult", error: "Marketplace manager is not available" }), + ) + }) +}) + describe("Project MCP Settings", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext diff --git a/src/core/webview/checkpointRestoreHandler.ts b/src/core/webview/checkpointRestoreHandler.ts index a3f62f74f3..5a517cdcc0 100644 --- a/src/core/webview/checkpointRestoreHandler.ts +++ b/src/core/webview/checkpointRestoreHandler.ts @@ -31,7 +31,7 @@ export async function handleCheckpointRestoreOperation(config: CheckpointRestore // This prevents "Current ask promise was ignored" errors // For edit operations, we don't abort because the checkpoint restore will handle it if (operation === "delete" && currentCline && !currentCline.abort) { - currentCline.abortTask() + await currentCline.abortTask() // Wait a bit for the abort to complete await pWaitFor(() => currentCline.abort === true, { timeout: 1000, diff --git a/src/core/webview/rulesMessageHandler.ts b/src/core/webview/rulesMessageHandler.ts index 343adf8738..68fcc5f7b2 100644 --- a/src/core/webview/rulesMessageHandler.ts +++ b/src/core/webview/rulesMessageHandler.ts @@ -38,7 +38,7 @@ export async function handleCreateRule( try { const input = parseCreateRuleInput(message) const createdPath = await createRule(cwd, input) - openFile(createdPath) + await openFile(createdPath) } catch (error) { const errorMessage = getErrorMessage(error) provider.log(`Error creating rule: ${errorMessage}`) @@ -89,7 +89,7 @@ export async function handleOpenRuleFile(provider: ClineProvider, cwd: string, m throw new Error("Rule file not found") } - openFile(filePath) + await openFile(filePath) } catch (error) { const errorMessage = getErrorMessage(error) provider.log(`Error opening rule file: ${errorMessage}`) @@ -109,7 +109,7 @@ export async function handleOpenRulesDirectory( kind: values.kind, modeSlug: values.modeSlug, } as CreateRuleInput) - openFile(directoryPath) + await openFile(directoryPath) } catch (error) { const errorMessage = getErrorMessage(error) provider.log(`Error opening rules directory: ${errorMessage}`) diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts index 496ff70c24..2eab270734 100644 --- a/src/core/webview/skillsMessageHandler.ts +++ b/src/core/webview/skillsMessageHandler.ts @@ -55,7 +55,7 @@ export async function handleCreateSkill( const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, modeSlugs) // Open the created file in the editor - openFile(createdPath) + await openFile(createdPath) // Send updated skills list const skills = skillsManager.getSkillsMetadata() @@ -199,7 +199,7 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv throw new Error(t("skills:errors.skill_not_found", { name: skillName })) } - openFile(skill.path) + await openFile(skill.path) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) provider.log(`Error opening skill file: ${errorMessage}`) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 3126d4ebf5..7009343573 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -563,17 +563,21 @@ export const webviewMessageHandler = async ( const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - provider.postStateToWebview() - provider.workspaceTracker?.initializeFilePaths() // Don't await. + await provider.postStateToWebview() + void provider.workspaceTracker + ?.initializeFilePaths() + .catch((err) => provider.log(`Workspace initialization error: ${err}`)) // Don't await. - getTheme().then((theme) => provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) })) + await getTheme().then((theme) => + provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }), + ) // If MCP Hub is already initialized, update the webview with // current server list. const mcpHub = provider.getMcpHub() if (mcpHub) { - provider.postMessageToWebview({ type: "mcpServers", mcpServers: mcpHub.getAllServers() }) + await provider.postMessageToWebview({ type: "mcpServers", mcpServers: mcpHub.getAllServers() }) } provider.providerSettingsManager @@ -630,7 +634,7 @@ export const webviewMessageHandler = async ( ) // Enable telemetry by default (when unset) or when explicitly enabled - provider.getStateToPostToWebview().then((state) => { + await provider.getStateToPostToWebview().then((state) => { const { telemetrySetting } = state const isOptedIn = telemetrySetting !== "disabled" TelemetryService.instance.updateTelemetryState(isOptedIn) @@ -786,7 +790,7 @@ export const webviewMessageHandler = async ( case "terminalOperation": if (message.terminalOperation) { - provider.getCurrentTask()?.handleTerminalOperation(message.terminalOperation) + await provider.getCurrentTask()?.handleTerminalOperation(message.terminalOperation) } break case "clearTask": @@ -812,7 +816,7 @@ export const webviewMessageHandler = async ( case "exportCurrentTask": const currentTaskId = provider.getCurrentTask()?.taskId if (currentTaskId) { - provider.exportTaskWithId(currentTaskId) + await provider.exportTaskWithId(currentTaskId) } break case "shareCurrentTask": @@ -826,13 +830,13 @@ export const webviewMessageHandler = async ( vscode.window.showErrorMessage(t("common:errors.share_not_enabled")) break case "showTaskWithId": - provider.showTaskWithId(message.text!) + await provider.showTaskWithId(message.text!) break case "condenseTaskContextRequest": - provider.condenseTaskContext(message.text!) + await provider.condenseTaskContext(message.text!) break case "deleteTaskWithId": - provider.deleteTaskWithId(message.text!) + await provider.deleteTaskWithId(message.text!) break case "abandonSubtaskWithId": provider @@ -888,7 +892,7 @@ export const webviewMessageHandler = async ( break } case "exportTaskWithId": - provider.exportTaskWithId(message.text!) + await provider.exportTaskWithId(message.text!) break case "getTaskWithAggregatedCosts": { try { @@ -1245,7 +1249,7 @@ export const webviewMessageHandler = async ( routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message. - provider.postMessageToWebview({ + void provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", success: false, error: errorMessage, @@ -1254,7 +1258,7 @@ export const webviewMessageHandler = async ( } }) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "routerModels", routerModels, values: providerFilter ? { provider: requestedProvider } : undefined, @@ -1284,7 +1288,7 @@ export const webviewMessageHandler = async ( } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: {}, error: errorMsg, @@ -1297,11 +1301,11 @@ export const webviewMessageHandler = async ( // Always post a response so the webview refresh status can // transition out of "loading" — even when no models are found. - provider.postMessageToWebview({ type: "ollamaModels", ollamaModels }) + await provider.postMessageToWebview({ type: "ollamaModels", ollamaModels }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: {}, error: errorMsg, @@ -1329,7 +1333,7 @@ export const webviewMessageHandler = async ( } if (Object.keys(lmStudioModels).length > 0) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "lmStudioModels", lmStudioModels: lmStudioModels, }) @@ -1341,7 +1345,7 @@ export const webviewMessageHandler = async ( break } case "requestRooModels": { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", success: false, error: getRouterRemovalMessage(), @@ -1357,24 +1361,24 @@ export const webviewMessageHandler = async ( message?.values?.openAiHeaders, ) - provider.postMessageToWebview({ type: "openAiModels", openAiModels }) + await provider.postMessageToWebview({ type: "openAiModels", openAiModels }) } break case "requestVsCodeLmModels": const vsCodeLmModels = await getVsCodeLmModels() // TODO: Cache like we do for OpenRouter, etc? - provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) + await provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) break case "openImage": - openImage(message.text!, { values: message.values }) + await openImage(message.text!, { values: message.values }) break case "saveImage": if (message.dataUri) { const matches = message.dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) if (!matches) { // Let saveImage handle invalid URI error - saveImage(message.dataUri, vscode.Uri.file("")) + await saveImage(message.dataUri, vscode.Uri.file("")) break } const format = matches[1] @@ -1402,12 +1406,12 @@ export const webviewMessageHandler = async ( if (!path.isAbsolute(filePath)) { filePath = path.join(getCurrentCwd(), filePath) } - openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) + await openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) break case "readFileContent": { const relPath = message.text || "" if (!relPath) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content: null, error: "No path provided" }, }) @@ -1416,7 +1420,7 @@ export const webviewMessageHandler = async ( try { const cwd = getCurrentCwd() if (!cwd) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content: null, error: "No workspace path available" }, }) @@ -1425,17 +1429,17 @@ export const webviewMessageHandler = async ( const absPath = path.resolve(cwd, relPath) // Workspace-boundary validation: prevent path traversal attacks if (isPathOutsideWorkspace(absPath)) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content: null, error: "Path is outside workspace" }, }) break } const content = await fs.readFile(absPath, "utf-8") - provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content } }) + await provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content } }) } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content: null, error: errorMsg }, }) @@ -1443,7 +1447,7 @@ export const webviewMessageHandler = async ( break } case "openMention": - openMention(getCurrentCwd(), message.text) + await openMention(getCurrentCwd(), message.text) break case "openExternal": if (message.url) { @@ -1573,7 +1577,7 @@ export const webviewMessageHandler = async ( const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() if (customModesFilePath) { - openFile(customModesFilePath) + await openFile(customModesFilePath) } break @@ -1600,7 +1604,7 @@ export const webviewMessageHandler = async ( const mcpSettingsFilePath = await provider.getMcpHub()?.getMcpSettingsFilePath() if (mcpSettingsFilePath) { - openFile(mcpSettingsFilePath) + await openFile(mcpSettingsFilePath) } break @@ -1737,7 +1741,7 @@ export const webviewMessageHandler = async ( break case "playTts": if (message.text) { - playTts(message.text, { + void playTts(message.text, { onStart: () => provider.postMessageToWebview({ type: "ttsStart", text: message.text }), onStop: () => provider.postMessageToWebview({ type: "ttsStop", text: message.text }), }) @@ -1820,7 +1824,7 @@ export const webviewMessageHandler = async ( customModePrompts: updatedPrompts, hasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false, } - provider.postMessageToWebview({ type: "state", state: stateWithPrompts }) + await provider.postMessageToWebview({ type: "state", state: stateWithPrompts }) if (TelemetryService.hasInstance()) { // Determine which setting was changed by comparing objects @@ -2225,7 +2229,7 @@ export const webviewMessageHandler = async ( try { const listApiConfig = await provider.providerSettingsManager.listConfig() await updateGlobalState("listApiConfigMeta", listApiConfig) - provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + await provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }) } catch (error) { provider.log( `Error get list api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2401,7 +2405,7 @@ export const webviewMessageHandler = async ( await fs.writeFile(saveUri.fsPath, result.yaml, "utf-8") // Send success message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "exportModeResult", success: true, slug: message.slug, @@ -2411,7 +2415,7 @@ export const webviewMessageHandler = async ( vscode.window.showInformationMessage(t("common:info.mode_exported", { mode: message.slug })) } else { // User cancelled the save dialog - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "exportModeResult", success: false, error: "Export cancelled", @@ -2420,7 +2424,7 @@ export const webviewMessageHandler = async ( } } else { // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "exportModeResult", success: false, error: result.error, @@ -2432,7 +2436,7 @@ export const webviewMessageHandler = async ( provider.log(`Failed to export mode ${message.slug}: ${errorMessage}`) // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "exportModeResult", success: false, error: errorMessage, @@ -2491,7 +2495,7 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() // Send success message to webview, include the imported slug so UI can switch - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "importModeResult", success: true, slug: result.slug, @@ -2501,7 +2505,7 @@ export const webviewMessageHandler = async ( vscode.window.showInformationMessage(t("common:info.mode_imported")) } else { // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "importModeResult", success: false, error: result.error, @@ -2512,7 +2516,7 @@ export const webviewMessageHandler = async ( } } else { // User cancelled the file dialog - reset the importing state - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "importModeResult", success: false, error: "cancelled", @@ -2523,7 +2527,7 @@ export const webviewMessageHandler = async ( provider.log(`Failed to import mode: ${errorMessage}`) // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "importModeResult", success: false, error: errorMessage, @@ -2537,7 +2541,7 @@ export const webviewMessageHandler = async ( if (message.slug) { const hasContent = await provider.customModesManager.checkRulesDirectoryHasContent(message.slug) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "checkRulesDirectoryResult", slug: message.slug, hasContent: hasContent, @@ -2615,14 +2619,14 @@ export const webviewMessageHandler = async ( case "rooCloudSignOut": { if (!isCloudServiceAvailable()) { await provider.postStateToWebview() - provider.postMessageToWebview({ type: "authenticatedUser", userInfo: undefined }) + await provider.postMessageToWebview({ type: "authenticatedUser", userInfo: undefined }) break } try { await CloudService.instance.logout() await provider.postStateToWebview() - provider.postMessageToWebview({ type: "authenticatedUser", userInfo: undefined }) + await provider.postMessageToWebview({ type: "authenticatedUser", userInfo: undefined }) } catch (error) { provider.log(`AuthService#logout failed: ${error}`) vscode.window.showErrorMessage("Sign out failed.") @@ -3025,7 +3029,7 @@ export const webviewMessageHandler = async ( const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { // No workspace open - send error status - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: { systemStatus: "Error", @@ -3050,7 +3054,7 @@ export const webviewMessageHandler = async ( workspacePath: undefined, } - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: status, }) @@ -3070,7 +3074,7 @@ export const webviewMessageHandler = async ( )) const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey")) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "codeIndexSecretStatus", values: { hasOpenAiKey, @@ -3088,7 +3092,7 @@ export const webviewMessageHandler = async ( try { const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: { systemStatus: "Error", @@ -3110,12 +3114,12 @@ export const webviewMessageHandler = async ( const currentState = manager.state if (currentState === "Standby" || currentState === "Error") { - manager.startIndexing() + void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) if (!manager.isInitialized) { await manager.initialize(provider.contextProxy) if (manager.state === "Standby" || manager.state === "Error") { - manager.startIndexing() + void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) } } } @@ -3133,7 +3137,7 @@ export const webviewMessageHandler = async ( return } manager.stopIndexing() - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: manager.getCurrentStatus(), }) @@ -3153,11 +3157,11 @@ export const webviewMessageHandler = async ( await manager.setWorkspaceEnabled(enabled) if (enabled && manager.isFeatureEnabled && manager.isFeatureConfigured) { await manager.initialize(provider.contextProxy) - manager.startIndexing() + void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) } else if (!enabled) { manager.stopIndexing() } - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: manager.getCurrentStatus(), }) @@ -3187,10 +3191,10 @@ export const webviewMessageHandler = async ( m.stopIndexing() } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { await m.initialize(provider.contextProxy) - m.startIndexing() + void m.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) } } - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: manager.getCurrentStatus(), }) @@ -3206,7 +3210,7 @@ export const webviewMessageHandler = async ( const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { provider.log("Cannot clear index data: No workspace folder open") - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexCleared", values: { success: false, @@ -3216,10 +3220,10 @@ export const webviewMessageHandler = async ( return } await manager.clearIndexData() - provider.postMessageToWebview({ type: "indexCleared", values: { success: true } }) + await provider.postMessageToWebview({ type: "indexCleared", values: { success: true } }) } catch (error) { provider.log(`Error clearing index data: ${error instanceof Error ? error.message : String(error)}`) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexCleared", values: { success: false, @@ -3268,7 +3272,7 @@ export const webviewMessageHandler = async ( console.log(`Marketplace item installed and config file opened: ${configFilePath}`) // Send success message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "marketplaceInstallResult", success: true, slug: message.mpItem.id, @@ -3276,7 +3280,7 @@ export const webviewMessageHandler = async ( } catch (error) { console.error(`Error installing marketplace item: ${error}`) // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "marketplaceInstallResult", success: false, error: error instanceof Error ? error.message : String(error), @@ -3294,7 +3298,7 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() // Send success message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "marketplaceRemoveResult", success: true, slug: message.mpItem.id, @@ -3308,7 +3312,7 @@ export const webviewMessageHandler = async ( ) // Send error message to webview - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "marketplaceRemoveResult", success: false, error: error instanceof Error ? error.message : String(error), @@ -3325,7 +3329,7 @@ export const webviewMessageHandler = async ( vscode.window.showErrorMessage(errorMessage) if (message.mpItem?.id) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "marketplaceRemoveResult", success: false, error: errorMessage, @@ -3441,7 +3445,7 @@ export const webviewMessageHandler = async ( const command = await getCommand(getCurrentCwd(), message.text) if (command && command.filePath) { - openFile(command.filePath) + await openFile(command.filePath) } else { vscode.window.showErrorMessage(t("common:errors.command_not_found", { name: message.text })) } @@ -3571,7 +3575,7 @@ export const webviewMessageHandler = async ( provider.log(`Created new command file: ${filePath}`) // Open the new file in the editor - openFile(filePath) + await openFile(filePath) // Refresh commands list const { getCommands } = await import("../../services/command/commands") @@ -3695,7 +3699,7 @@ export const webviewMessageHandler = async ( const accessToken = await openAiCodexOAuthManager.getAccessToken() if (!accessToken) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "openAiCodexRateLimits", error: "Not authenticated with OpenAI Codex", }) @@ -3706,14 +3710,14 @@ export const webviewMessageHandler = async ( const { fetchOpenAiCodexRateLimitInfo } = await import("../../integrations/openai-codex/rate-limits") const rateLimits = await fetchOpenAiCodexRateLimitInfo(accessToken, { accountId }) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "openAiCodexRateLimits", values: rateLimits, }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) provider.log(`Error fetching OpenAI Codex rate limits: ${errorMessage}`) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "openAiCodexRateLimits", error: errorMessage, }) @@ -3839,7 +3843,7 @@ export const webviewMessageHandler = async ( createNewBranch: message.worktreeCreateNewBranch, }, (progress) => { - provider.postMessageToWebview({ + void provider.postMessageToWebview({ type: "worktreeCopyProgress", copyProgressBytesCopied: progress.bytesCopied, copyProgressItemName: progress.itemName, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 4b0f48253f..608e190d04 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -289,11 +289,6 @@ "count": 5 } }, - "api/providers/__tests__/vscode-lm.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "api/providers/__tests__/xai.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -479,11 +474,6 @@ "count": 7 } }, - "api/providers/vscode-lm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "api/providers/xai.ts": { "@typescript-eslint/no-explicit-any": { "count": 7 @@ -544,11 +534,6 @@ "count": 5 } }, - "api/transform/__tests__/vscode-lm-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, "api/transform/__tests__/zai-format.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 @@ -599,11 +584,6 @@ "count": 4 } }, - "api/transform/vscode-lm-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "api/transform/zai-format.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 @@ -866,7 +846,7 @@ }, "core/task/__tests__/Task.dispose.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 8 + "count": 3 } }, "core/task/__tests__/Task.persistence.spec.ts": { diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index cea212fefa..65965eb8d5 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -34,7 +34,7 @@ export default [ { // Ratchet: enforce no-floating-promises directory by directory. Each // directory is added here once its floating promises are resolved. - files: ["activate/**/*.ts", "core/task/**/*.ts"], + files: ["activate/**/*.ts", "core/task/**/*.ts", "core/webview/**/*.ts"], languageOptions: { parserOptions: { project: true, diff --git a/src/i18n/__tests__/tools-missing-param.spec.ts b/src/i18n/__tests__/tools-missing-param.spec.ts new file mode 100644 index 0000000000..f164bf06be --- /dev/null +++ b/src/i18n/__tests__/tools-missing-param.spec.ts @@ -0,0 +1,46 @@ +import fs from "fs" +import path from "path" + +const localesDir = path.join(__dirname, "..", "locales") + +const languages = fs + .readdirSync(localesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".")) + .map((dirent) => dirent.name) + +function loadTools(language: string): Record { + return JSON.parse(fs.readFileSync(path.join(localesDir, language, "tools.json"), "utf8")) +} + +describe("tools:missingToolParameter locales", () => { + it("covers every shipped locale", () => { + expect(languages.length).toBeGreaterThan(0) + for (const language of languages) { + const tools = loadTools(language) + expect(tools.missingToolParameter, `missingToolParameter missing in ${language}`).toBeTruthy() + expect( + tools.missingToolParameterWithPath, + `missingToolParameterWithPath missing in ${language}`, + ).toBeTruthy() + } + }) + + it("keeps all interpolation placeholders in every locale", () => { + for (const language of languages) { + const tools = loadTools(language) + expect(tools.missingToolParameter, `placeholders missing in ${language}`).toContain("{{toolName}}") + expect(tools.missingToolParameter, `placeholders missing in ${language}`).toContain("{{paramName}}") + expect(tools.missingToolParameterWithPath, `placeholders missing in ${language}`).toContain("{{toolName}}") + expect(tools.missingToolParameterWithPath, `placeholders missing in ${language}`).toContain("{{relPath}}") + expect(tools.missingToolParameterWithPath, `placeholders missing in ${language}`).toContain("{{paramName}}") + } + }) + + it("brands the English messages as Zoo, not Roo", () => { + const tools = loadTools("en") + expect(tools.missingToolParameter).toContain("Zoo") + expect(tools.missingToolParameter).not.toContain("Roo") + expect(tools.missingToolParameterWithPath).toContain("Zoo") + expect(tools.missingToolParameterWithPath).not.toContain("Roo") + }) +}) diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 223264252e..93f363cf25 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.", "unknownToolError": "Zoo ha intentat utilitzar una eina desconeguda: \"{{toolName}}\". Reintentant...", + "missingToolParameter": "Zoo ha intentat utilitzar {{toolName}} sense valor per al paràmetre obligatori '{{paramName}}'. Reintentant...", + "missingToolParameterWithPath": "Zoo ha intentat utilitzar {{toolName}} per a '{{relPath}}' sense valor per al paràmetre obligatori '{{paramName}}'. Reintentant...", "codebaseSearch": { "approval": "Cercant '{{query}}' a la base de codi..." }, diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index d04362f2d9..c1f358a01e 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Zoo zu einem anderen Ansatz zu führen.", "unknownToolError": "Zoo hat versucht, ein unbekanntes Tool zu verwenden: \"{{toolName}}\". Wiederhole Versuch...", + "missingToolParameter": "Zoo hat versucht, {{toolName}} ohne Wert für den erforderlichen Parameter '{{paramName}}' zu verwenden. Wiederhole Versuch...", + "missingToolParameterWithPath": "Zoo hat versucht, {{toolName}} für '{{relPath}}' ohne Wert für den erforderlichen Parameter '{{paramName}}' zu verwenden. Wiederhole Versuch...", "codebaseSearch": { "approval": "Suche nach '{{query}}' im Codebase..." }, diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 9c08368e53..acde8f23bd 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.", "unknownToolError": "Zoo tried to use an unknown tool: \"{{toolName}}\". Retrying...", + "missingToolParameter": "Zoo tried to use {{toolName}} without value for required parameter '{{paramName}}'. Retrying...", + "missingToolParameterWithPath": "Zoo tried to use {{toolName}} for '{{relPath}}' without value for required parameter '{{paramName}}'. Retrying...", "codebaseSearch": { "approval": "Searching for '{{query}}' in codebase..." }, diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index f865a8e484..48d6e4da39 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.", "unknownToolError": "Zoo intentó usar una herramienta desconocida: \"{{toolName}}\". Reintentando...", + "missingToolParameter": "Zoo intentó usar {{toolName}} sin valor para el parámetro requerido '{{paramName}}'. Reintentando...", + "missingToolParameterWithPath": "Zoo intentó usar {{toolName}} para '{{relPath}}' sin valor para el parámetro requerido '{{paramName}}'. Reintentando...", "codebaseSearch": { "approval": "Buscando '{{query}}' en la base de código..." }, diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index 7dd65facc4..45d6ba3325 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.", "unknownToolError": "Zoo a tenté d'utiliser un outil inconnu : \"{{toolName}}\". Nouvelle tentative...", + "missingToolParameter": "Zoo a tenté d'utiliser {{toolName}} sans valeur pour le paramètre requis '{{paramName}}'. Nouvelle tentative...", + "missingToolParameterWithPath": "Zoo a tenté d'utiliser {{toolName}} pour '{{relPath}}' sans valeur pour le paramètre requis '{{paramName}}'. Nouvelle tentative...", "codebaseSearch": { "approval": "Recherche de '{{query}}' dans la base de code..." }, diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 04193d45a0..e377c46f09 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", "unknownToolError": "Zoo ने एक अज्ञात उपकरण का उपयोग करने का प्रयास किया: \"{{toolName}}\"। पुनः प्रयास कर रहा है...", + "missingToolParameter": "Zoo ने आवश्यक पैरामीटर '{{paramName}}' के मान के बिना {{toolName}} का उपयोग करने का प्रयास किया। पुनः प्रयास कर रहा है...", + "missingToolParameterWithPath": "Zoo ने '{{relPath}}' के लिए आवश्यक पैरामीटर '{{paramName}}' के मान के बिना {{toolName}} का उपयोग करने का प्रयास किया। पुनः प्रयास कर रहा है...", "codebaseSearch": { "approval": "कोडबेस में '{{query}}' खोज रहा है..." }, diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index b6b64fdcae..04d1d16daa 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo tampaknya terjebak dalam loop, mencoba aksi yang sama ({{toolName}}) berulang kali. Ini mungkin menunjukkan masalah dengan strategi saat ini. Pertimbangkan untuk mengubah frasa tugas, memberikan instruksi yang lebih spesifik, atau mengarahkannya ke pendekatan yang berbeda.", "unknownToolError": "Zoo mencoba menggunakan alat yang tidak dikenal: \"{{toolName}}\". Mencoba lagi...", + "missingToolParameter": "Zoo mencoba menggunakan {{toolName}} tanpa nilai untuk parameter wajib '{{paramName}}'. Mencoba lagi...", + "missingToolParameterWithPath": "Zoo mencoba menggunakan {{toolName}} untuk '{{relPath}}' tanpa nilai untuk parameter wajib '{{paramName}}'. Mencoba lagi...", "codebaseSearch": { "approval": "Mencari '{{query}}' di codebase..." }, diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index 97d851bd7f..e384a0ea80 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.", "unknownToolError": "Zoo ha provato ad utilizzare uno strumento sconosciuto: \"{{toolName}}\". Nuovo tentativo...", + "missingToolParameter": "Zoo ha provato a utilizzare {{toolName}} senza valore per il parametro obbligatorio '{{paramName}}'. Nuovo tentativo...", + "missingToolParameterWithPath": "Zoo ha provato a utilizzare {{toolName}} per '{{relPath}}' senza valore per il parametro obbligatorio '{{paramName}}'. Nuovo tentativo...", "codebaseSearch": { "approval": "Ricerca di '{{query}}' nella base di codice..." }, diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index 3987b7b745..f319b0b768 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", "unknownToolError": "Zooが不明なツールを使用しようとしました:「{{toolName}}」。再試行中...", + "missingToolParameter": "Zooが必須パラメータ '{{paramName}}' の値なしで {{toolName}} を使用しようとしました。再試行中...", + "missingToolParameterWithPath": "Zooが '{{relPath}}' に対して必須パラメータ '{{paramName}}' の値なしで {{toolName}} を使用しようとしました。再試行中...", "codebaseSearch": { "approval": "コードベースで '{{query}}' を検索中..." }, diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index cc65e2a80b..6b8e91726b 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", "unknownToolError": "Zoo가 알 수 없는 도구를 사용하려고 했습니다: \"{{toolName}}\". 다시 시도 중...", + "missingToolParameter": "Zoo가 필수 매개변수 '{{paramName}}'의 값 없이 {{toolName}}을(를) 사용하려고 했습니다. 다시 시도 중...", + "missingToolParameterWithPath": "Zoo가 '{{relPath}}'에 대해 필수 매개변수 '{{paramName}}'의 값 없이 {{toolName}}을(를) 사용하려고 했습니다. 다시 시도 중...", "codebaseSearch": { "approval": "코드베이스에서 '{{query}}' 검색 중..." }, diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index d157275a7a..5a5df015a7 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo lijkt vast te zitten in een lus, waarbij hij herhaaldelijk dezelfde actie ({{toolName}}) probeert. Dit kan duiden op een probleem met de huidige strategie. Overweeg de taak te herformuleren, specifiekere instructies te geven of Zoo naar een andere aanpak te leiden.", "unknownToolError": "Zoo probeerde een onbekende tool te gebruiken: \"{{toolName}}\". Opnieuw proberen...", + "missingToolParameter": "Zoo probeerde {{toolName}} te gebruiken zonder waarde voor de vereiste parameter '{{paramName}}'. Opnieuw proberen...", + "missingToolParameterWithPath": "Zoo probeerde {{toolName}} te gebruiken voor '{{relPath}}' zonder waarde voor de vereiste parameter '{{paramName}}'. Opnieuw proberen...", "codebaseSearch": { "approval": "Zoeken naar '{{query}}' in codebase..." }, diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index fce837aafb..32a3e27f49 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Wygląda na to, że Zoo utknął w pętli, wielokrotnie próbując wykonać tę samą akcję ({{toolName}}). Może to wskazywać na problem z jego obecną strategią. Rozważ przeformułowanie zadania, podanie bardziej szczegółowych instrukcji lub nakierowanie go na inne podejście.", "unknownToolError": "Zoo próbował użyć nieznanego narzędzia: \"{{toolName}}\". Ponowna próba...", + "missingToolParameter": "Zoo próbował użyć {{toolName}} bez wartości dla wymaganego parametru '{{paramName}}'. Ponowna próba...", + "missingToolParameterWithPath": "Zoo próbował użyć {{toolName}} dla '{{relPath}}' bez wartości dla wymaganego parametru '{{paramName}}'. Ponowna próba...", "codebaseSearch": { "approval": "Wyszukiwanie '{{query}}' w bazie kodu..." }, diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 6505067607..28e950db9c 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo parece estar preso em um loop, tentando a mesma ação ({{toolName}}) repetidamente. Isso pode indicar um problema com sua estratégia atual. Considere reformular a tarefa, fornecer instruções mais específicas ou guiá-lo para uma abordagem diferente.", "unknownToolError": "Zoo tentou usar uma ferramenta desconhecida: \"{{toolName}}\". Tentando novamente...", + "missingToolParameter": "Zoo tentou usar {{toolName}} sem valor para o parâmetro obrigatório '{{paramName}}'. Tentando novamente...", + "missingToolParameterWithPath": "Zoo tentou usar {{toolName}} para '{{relPath}}' sem valor para o parâmetro obrigatório '{{paramName}}'. Tentando novamente...", "codebaseSearch": { "approval": "Pesquisando '{{query}}' na base de código..." }, diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 6fecfc272b..6d20010a45 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Похоже, что Zoo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", "unknownToolError": "Zoo попытался использовать неизвестный инструмент: \"{{toolName}}\". Повторная попытка...", + "missingToolParameter": "Zoo попытался использовать {{toolName}} без значения для обязательного параметра '{{paramName}}'. Повторная попытка...", + "missingToolParameterWithPath": "Zoo попытался использовать {{toolName}} для '{{relPath}}' без значения для обязательного параметра '{{paramName}}'. Повторная попытка...", "codebaseSearch": { "approval": "Поиск '{{query}}' в кодовой базе..." }, diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index d2a79a9fab..de447fcf21 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo bir döngüye takılmış gibi görünüyor, aynı eylemi ({{toolName}}) tekrar tekrar deniyor. Bu, mevcut stratejisinde bir sorun olduğunu gösterebilir. Görevi yeniden ifade etmeyi, daha spesifik talimatlar vermeyi veya onu farklı bir yaklaşıma yönlendirmeyi düşünün.", "unknownToolError": "Zoo bilinmeyen bir araç kullanmaya çalıştı: \"{{toolName}}\". Yeniden deneniyor...", + "missingToolParameter": "Zoo, gerekli '{{paramName}}' parametresi için değer olmadan {{toolName}} kullanmaya çalıştı. Yeniden deneniyor...", + "missingToolParameterWithPath": "Zoo, '{{relPath}}' için gerekli '{{paramName}}' parametresi için değer olmadan {{toolName}} kullanmaya çalıştı. Yeniden deneniyor...", "codebaseSearch": { "approval": "Kod tabanında '{{query}}' aranıyor..." }, diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 96c0174317..ec43f2b39e 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo dường như đang bị mắc kẹt trong một vòng lặp, liên tục cố gắng thực hiện cùng một hành động ({{toolName}}). Điều này có thể cho thấy vấn đề với chiến lược hiện tại. Hãy cân nhắc việc diễn đạt lại nhiệm vụ, cung cấp hướng dẫn cụ thể hơn, hoặc hướng Zoo theo một cách tiếp cận khác.", "unknownToolError": "Zoo đã cố gắng sử dụng một công cụ không xác định: \"{{toolName}}\". Đang thử lại...", + "missingToolParameter": "Zoo đã cố gắng sử dụng {{toolName}} mà không có giá trị cho tham số bắt buộc '{{paramName}}'. Đang thử lại...", + "missingToolParameterWithPath": "Zoo đã cố gắng sử dụng {{toolName}} cho '{{relPath}}' mà không có giá trị cho tham số bắt buộc '{{paramName}}'. Đang thử lại...", "codebaseSearch": { "approval": "Đang tìm kiếm '{{query}}' trong cơ sở mã..." }, diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index 9a16e15ac5..d5a5701f3d 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo 似乎陷入循环,反复尝试同一操作 ({{toolName}})。这可能表明当前策略存在问题。请考虑重新描述任务、提供更具体的指示或引导其尝试不同的方法。", "unknownToolError": "Zoo 尝试使用未知工具:\"{{toolName}}\"。正在重试...", + "missingToolParameter": "Zoo 尝试使用 {{toolName}} 但未提供必需参数 '{{paramName}}' 的值。正在重试...", + "missingToolParameterWithPath": "Zoo 尝试对 '{{relPath}}' 使用 {{toolName}} 但未提供必需参数 '{{paramName}}' 的值。正在重试...", "codebaseSearch": { "approval": "正在搜索代码库中的 '{{query}}'..." }, diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index 0d35605211..02623543b9 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -8,6 +8,8 @@ }, "toolRepetitionLimitReached": "Zoo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", "unknownToolError": "Zoo 嘗試使用未知工具:「{{toolName}}」。正在重試...", + "missingToolParameter": "Zoo 嘗試使用 {{toolName}} 但未提供必要參數 '{{paramName}}' 的值。正在重試...", + "missingToolParameterWithPath": "Zoo 嘗試對 '{{relPath}}' 使用 {{toolName}} 但未提供必要參數 '{{paramName}}' 的值。正在重試...", "codebaseSearch": { "approval": "正在搜尋程式碼庫中的「{{query}}」..." }, diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 5c32e567e2..ab92beea36 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/shared/__tests__/checkExistApiConfig.spec.ts -import type { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" import { checkExistKey } from "../checkExistApiConfig" @@ -66,16 +66,20 @@ describe("checkExistKey", () => { expect(checkExistKey(config)).toBe(true) }) + it("recognizes keyless providers through their canonical identifiers", () => { + expect(checkExistKey({ apiProvider: providerIdentifiers.fakeAi })).toBe(true) + }) + it("should return true for openai-codex provider without API key", () => { const config: ProviderSettings = { - apiProvider: "openai-codex", + apiProvider: providerIdentifiers.openaiCodex, } expect(checkExistKey(config)).toBe(true) }) it("should return true for qwen-code provider without API key", () => { const config: ProviderSettings = { - apiProvider: "qwen-code", + apiProvider: providerIdentifiers.qwenCode, } expect(checkExistKey(config)).toBe(true) }) @@ -95,6 +99,10 @@ describe("checkExistKey", () => { expect(checkExistKey(config)).toBe(true) }) + it("recognizes OAuth authentication through the canonical Kimi Code identifier", () => { + expect(checkExistKey({ apiProvider: providerIdentifiers.kimiCode, kimiCodeAuthMethod: "oauth" })).toBe(true) + }) + it("should return true for kimi-code provider without auth method (defaults to OAuth)", () => { const config: ProviderSettings = { apiProvider: "kimi-code", @@ -128,6 +136,10 @@ describe("checkExistKey", () => { expect(checkExistKey(config, false)).toBe(false) }) + it("recognizes session authentication through the canonical Zoo Gateway identifier", () => { + expect(checkExistKey({ apiProvider: providerIdentifiers.zooGateway }, true)).toBe(true) + }) + it("should return true for zoo-gateway when profile has zooSessionToken", () => { const config: ProviderSettings = { apiProvider: "zoo-gateway", diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 9ce55a9ac3..3560199b91 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -1,4 +1,4 @@ -import { SECRET_STATE_KEYS, GLOBAL_SECRET_KEYS, ProviderSettings } from "@roo-code/types" +import { SECRET_STATE_KEYS, GLOBAL_SECRET_KEYS, providerIdentifiers, ProviderSettings } from "@roo-code/types" /** * Returns whether a provider profile is sufficiently configured to leave the @@ -14,17 +14,22 @@ export function checkExistKey(config: ProviderSettings | undefined, zooCodeIsAut } // Special case for fake-ai, openai-codex, and qwen-code providers which don't need any configuration. - if (config.apiProvider && ["fake-ai", "openai-codex", "qwen-code"].includes(config.apiProvider)) { + const configurationFreeProviders: ProviderSettings["apiProvider"][] = [ + providerIdentifiers.fakeAi, + providerIdentifiers.openaiCodex, + providerIdentifiers.qwenCode, + ] + if (config.apiProvider && configurationFreeProviders.includes(config.apiProvider)) { return true } - if (config.apiProvider === "kimi-code" && (config.kimiCodeAuthMethod ?? "oauth") === "oauth") { + if (config.apiProvider === providerIdentifiers.kimiCode && (config.kimiCodeAuthMethod ?? "oauth") === "oauth") { return true } // Zoo Gateway uses session auth (profile token and/or global Zoo Code login), // not a traditional API key listed in SECRET_STATE_KEYS. - if (config.apiProvider === "zoo-gateway") { + if (config.apiProvider === providerIdentifiers.zooGateway) { return Boolean(config.zooSessionToken) || Boolean(zooCodeIsAuthenticated) } diff --git a/src/shared/cost.ts b/src/shared/cost.ts index 8954904fda..754a2d84df 100644 --- a/src/shared/cost.ts +++ b/src/shared/cost.ts @@ -1,5 +1,4 @@ -import type { ModelInfo } from "@roo-code/types" -import type { ServiceTier } from "@roo-code/types" +import { OpenAiServiceTier, type ModelInfo, type ServiceTier } from "@roo-code/types" export interface ApiCostResult { totalInputTokens: number @@ -13,7 +12,7 @@ function applyLongContextPricing(modelInfo: ModelInfo, totalInputTokens: number, return modelInfo } - const effectiveServiceTier = serviceTier ?? "default" + const effectiveServiceTier = serviceTier ?? OpenAiServiceTier.Default if (pricing.appliesToServiceTiers && !pricing.appliesToServiceTiers.includes(effectiveServiceTier)) { return modelInfo } diff --git a/src/utils/__tests__/cost.spec.ts b/src/utils/__tests__/cost.spec.ts index 6f0b594c8d..b369a0405b 100644 --- a/src/utils/__tests__/cost.spec.ts +++ b/src/utils/__tests__/cost.spec.ts @@ -1,6 +1,6 @@ // npx vitest utils/__tests__/cost.spec.ts -import type { ModelInfo } from "@roo-code/types" +import { OpenAiServiceTier, type ModelInfo } from "@roo-code/types" import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" @@ -283,7 +283,7 @@ describe("Cost Utility", () => { thresholdTokens: 272_000, inputPriceMultiplier: 2, outputPriceMultiplier: 1.5, - appliesToServiceTiers: ["default", "flex"], + appliesToServiceTiers: [OpenAiServiceTier.Default, OpenAiServiceTier.Flex], }, } @@ -293,7 +293,7 @@ describe("Cost Utility", () => { 1_000, undefined, 100_000, - "priority", + OpenAiServiceTier.Priority, ) // Input cost: (5.0 / 1_000_000) * (300000 - 100000) = 1.0 diff --git a/webview-ui/playwright-ct.config.ts b/webview-ui/playwright-ct.config.ts index 8b236781b2..3eb0abac7b 100644 --- a/webview-ui/playwright-ct.config.ts +++ b/webview-ui/playwright-ct.config.ts @@ -58,10 +58,15 @@ export default defineConfig({ ], resolve: { alias: { + "@src/i18n/TranslationContext": path.resolve(dirname, "./playwright/TranslationContext.ts"), "@": path.resolve(dirname, "./src"), "@src": path.resolve(dirname, "./src"), "@roo": path.resolve(dirname, "../src/shared"), - vscode: path.resolve(dirname, "./src/__mocks__/vscode.ts"), + "@vscode/webview-ui-toolkit/react": path.resolve( + dirname, + "./src/__mocks__/@vscode/webview-ui-toolkit/react.tsx", + ), + vscode: path.resolve(dirname, "../src/__mocks__/vscode.js"), }, }, define: { diff --git a/webview-ui/playwright/TranslationContext.ts b/webview-ui/playwright/TranslationContext.ts new file mode 100644 index 0000000000..b2024e29d9 --- /dev/null +++ b/webview-ui/playwright/TranslationContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react" + +type TranslationContextValue = { + t: (key: string, options?: Record) => string + i18n: unknown +} + +export const TranslationContext = createContext({ + t: (key: string) => key, + i18n: null, +}) + +export const useAppTranslation = () => useContext(TranslationContext) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 01c4796c16..0410336609 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -462,6 +462,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction { }), ) }) + + it("clears the command_output controls when the final output arrives", async () => { + const { getByTestId, getByRole, queryByRole } = renderChatView() + + // Hydrate state with a command_output ask (Proceed/Kill controls visible) + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + }, + { + type: "ask", + ask: "command_output", + ts: Date.now() - 1000, + text: "", + partial: false, + }, + ], + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + expect(getByRole("button", { name: "chat:proceedWhileRunning.title" })).toBeInTheDocument() + + // The command completes: the final non-partial command_output say arrives. + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + }, + { + type: "ask", + ask: "command_output", + ts: Date.now() - 1000, + text: "", + partial: false, + }, + { + type: "say", + say: "command_output", + ts: Date.now(), + text: "done\n", + partial: false, + }, + ], + }) + + await waitFor(() => { + expect(queryByRole("button", { name: "chat:proceedWhileRunning.title" })).not.toBeInTheDocument() + }) + }) }) describe("ChatView - Follow-up Suggestions", () => { diff --git a/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx b/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx index b04ab1163e..b00a36d1ac 100644 --- a/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx +++ b/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useRef, useState } from "react" import { useRemark } from "react-remark" import { cn } from "@/lib/utils" -import { Collapsible, CollapsibleTrigger } from "@/components/ui" +import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible" import { StyledMarkdown } from "./styles" diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index e043f68f83..fff55eda55 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -1,6 +1,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import type { ModelInfo } from "@roo-code/types" +import { OpenAiServiceTier, type ModelInfo, type ServiceTier } from "@roo-code/types/model" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { formatPrice } from "@src/utils/formatPrice" import { cn } from "@src/lib/utils" @@ -17,6 +18,26 @@ type ModelInfoViewProps = { hidePricing?: boolean } +type TierPricingRowProps = { + tier: ServiceTier + label: string + modelInfo?: ModelInfo +} + +const TierPricingRow = ({ tier, label, modelInfo }: TierPricingRowProps) => { + const tierInfo = modelInfo?.tiers?.find(({ name }) => name === tier) + const fmt = (price?: number) => (typeof price === "number" ? formatPrice(price) : "—") + + return ( + + {label} + {fmt(tierInfo?.inputPrice ?? modelInfo?.inputPrice)} + {fmt(tierInfo?.outputPrice ?? modelInfo?.outputPrice)} + {fmt(tierInfo?.cacheReadsPrice ?? modelInfo?.cacheReadsPrice)} + + ) +} + export const ModelInfoView = ({ apiProvider, selectedModelId, @@ -29,9 +50,11 @@ export const ModelInfoView = ({ // Show tiered pricing table for OpenAI Native when model supports non-standard tiers const allowedTierNames = - modelInfo?.tiers?.filter((t) => t.name === "flex" || t.name === "priority")?.map((t) => t.name) ?? [] - const shouldShowTierPricingTable = apiProvider === "openai-native" && allowedTierNames.length > 0 - const fmt = (n?: number) => (typeof n === "number" ? `${formatPrice(n)}` : "—") + modelInfo?.tiers + ?.filter((t) => t.name === OpenAiServiceTier.Flex || t.name === OpenAiServiceTier.Priority) + ?.map((t) => t.name) ?? [] + const shouldShowTierPricingTable = apiProvider === providerIdentifiers.openaiNative && allowedTierNames.length > 0 + const fmt = (n?: number) => (typeof n === "number" ? formatPrice(n) : "—") const baseInfoItems = [ typeof modelInfo?.contextWindow === "number" && modelInfo.contextWindow > 0 && ( @@ -144,51 +167,19 @@ export const ModelInfoView = ({ {fmt(modelInfo?.outputPrice)} {fmt(modelInfo?.cacheReadsPrice)} - {allowedTierNames.includes("flex") && ( - - {t("settings:serviceTier.flex")} - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "flex")?.inputPrice ?? - modelInfo?.inputPrice, - )} - - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "flex")?.outputPrice ?? - modelInfo?.outputPrice, - )} - - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "flex")?.cacheReadsPrice ?? - modelInfo?.cacheReadsPrice, - )} - - + {allowedTierNames.includes(OpenAiServiceTier.Flex) && ( + )} - {allowedTierNames.includes("priority") && ( - - {t("settings:serviceTier.priority")} - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "priority")?.inputPrice ?? - modelInfo?.inputPrice, - )} - - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "priority")?.outputPrice ?? - modelInfo?.outputPrice, - )} - - - {fmt( - modelInfo?.tiers?.find((t) => t.name === "priority")?.cacheReadsPrice ?? - modelInfo?.cacheReadsPrice, - )} - - + {allowedTierNames.includes(OpenAiServiceTier.Priority) && ( + )} diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx new file mode 100644 index 0000000000..938bc8bff7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -0,0 +1,129 @@ +import { OpenAiServiceTier, providerIdentifiers, type ModelInfo } from "@roo-code/types" + +import { render, screen, within } from "@/utils/test-utils" + +import { ModelInfoView } from "../ModelInfoView" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => + ({ + "settings:serviceTier.pricingTableTitle": "Service tier pricing", + "settings:serviceTier.columns.tier": "Tier", + "settings:serviceTier.columns.input": "Input", + "settings:serviceTier.columns.output": "Output", + "settings:serviceTier.columns.cacheReads": "Cache reads", + "settings:serviceTier.standard": "Standard", + "settings:serviceTier.flex": "Flex", + "settings:serviceTier.priority": "Priority", + })[key] ?? key, + }), +})) + +const baseModelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + inputPrice: 10, + outputPrice: 20, + cacheReadsPrice: 3, +} + +const defaultProps = { + selectedModelId: "gpt-test", + isDescriptionExpanded: false, + setIsDescriptionExpanded: vi.fn(), +} + +const getPricingRowValues = (tier: string) => { + const row = screen.getByRole("cell", { name: tier }).closest("tr") + expect(row).not.toBeNull() + return within(row!) + .getAllByRole("cell") + .map((cell) => cell.textContent) +} + +describe("ModelInfoView service tier pricing", () => { + it("shows OpenAI Native tier prices with per-field fallback to Standard pricing", () => { + const modelInfo: ModelInfo = { + ...baseModelInfo, + tiers: [ + { name: OpenAiServiceTier.Default, contextWindow: 128_000 }, + { + name: OpenAiServiceTier.Flex, + contextWindow: 128_000, + inputPrice: 4, + cacheReadsPrice: 1, + }, + { + name: OpenAiServiceTier.Priority, + contextWindow: 128_000, + outputPrice: 40, + }, + ], + } + + render() + + expect(screen.getByText("Service tier pricing")).toBeInTheDocument() + expect(getPricingRowValues("Standard")).toEqual(["Standard", "$10.00", "$20.00", "$3.00"]) + expect(getPricingRowValues("Flex")).toEqual(["Flex", "$4.00", "$20.00", "$1.00"]) + expect(getPricingRowValues("Priority")).toEqual(["Priority", "$10.00", "$40.00", "$3.00"]) + }) + + it("only shows the tier pricing table for OpenAI Native models with a non-standard tier", () => { + const tieredModelInfo: ModelInfo = { + ...baseModelInfo, + tiers: [{ name: OpenAiServiceTier.Flex, contextWindow: 128_000 }], + } + const { rerender } = render( + , + ) + + expect(screen.queryByText("Service tier pricing")).not.toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText("Service tier pricing")).not.toBeInTheDocument() + }) + + it("does not show the tier pricing table when model info has no tiers", () => { + render( + , + ) + + expect(screen.queryByText("Service tier pricing")).not.toBeInTheDocument() + }) + + it("shows unavailable prices when neither the tier nor Standard defines them", () => { + render( + , + ) + + expect(getPricingRowValues("Flex")).toEqual(["Flex", "—", "—", "—"]) + expect(getPricingRowValues("Priority")).toEqual(["Priority", "—", "—", "—"]) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx new file mode 100644 index 0000000000..b007d4be69 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx @@ -0,0 +1,68 @@ +import React from "react" + +import { OpenAiServiceTier, type ModelInfo } from "@roo-code/types/model" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { TranslationContext } from "@src/i18n/TranslationContext" +import { ModelInfoView } from "../ModelInfoView" + +const modelInfo: ModelInfo = { + contextWindow: 400_000, + maxTokens: 128_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 2, + outputPrice: 8, + cacheReadsPrice: 0.5, + tiers: [ + { + name: OpenAiServiceTier.Flex, + contextWindow: 400_000, + inputPrice: 1, + outputPrice: 4, + cacheReadsPrice: 0.25, + }, + { + name: OpenAiServiceTier.Priority, + contextWindow: 400_000, + inputPrice: 3.5, + outputPrice: 14, + cacheReadsPrice: 0.875, + }, + ], +} + +const translations: Record = { + "settings:modelInfo.contextWindow": "Context window:", + "settings:modelInfo.maxOutput": "Max output", + "settings:modelInfo.supportsImages": "Supports images", + "settings:modelInfo.noImages": "Does not support images", + "settings:modelInfo.supportsPromptCache": "Supports prompt caching", + "settings:modelInfo.noPromptCache": "Does not support prompt caching", + "settings:serviceTier.pricingTableTitle": "Service tier pricing (per 1M tokens)", + "settings:serviceTier.columns.tier": "Tier", + "settings:serviceTier.columns.input": "Input", + "settings:serviceTier.columns.output": "Output", + "settings:serviceTier.columns.cacheReads": "Cache reads", + "settings:serviceTier.standard": "Standard", + "settings:serviceTier.flex": "Flex", + "settings:serviceTier.priority": "Priority", +} + +export const ModelInfoViewFixture = () => ( + translations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, + }}> +
+ {}} + /> +
+
+) diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.tsx new file mode 100644 index 0000000000..0b5074e761 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.tsx @@ -0,0 +1,15 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { ModelInfoViewFixture } from "./ModelInfoView.visual.fixture" + +test("renders OpenAI service tier pricing in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("model-info-service-tier-pricing-dark.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png new file mode 100644 index 0000000000..0a3bee5351 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/model-info-service-tier-pricing-dark.png differ diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx index 96fd6c89be..460019e75a 100644 --- a/webview-ui/src/components/settings/providers/OpenAI.tsx +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react" import { Checkbox } from "vscrui" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import type { ModelInfo, ProviderSettings } from "@roo-code/types" +import { OpenAiServiceTier, type ModelInfo, type ProviderSettings } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" @@ -78,7 +78,7 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod {(() => { const allowedTiers = (selectedModelInfo?.tiers?.map((t) => t.name).filter(Boolean) || []).filter( - (t) => t === "flex" || t === "priority", + (t) => t === OpenAiServiceTier.Flex || t === OpenAiServiceTier.Priority, ) if (allowedTiers.length === 0) return null @@ -92,7 +92,7 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx new file mode 100644 index 0000000000..239c76932f --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx @@ -0,0 +1,92 @@ +import React from "react" + +import { OpenAiServiceTier, providerIdentifiers, type ModelInfo, type ProviderSettings } from "@roo-code/types" + +import { fireEvent, render, screen } from "@/utils/test-utils" + +import { OpenAI } from "../OpenAI" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("vscrui", () => ({ + Checkbox: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Select: ({ children, value, onValueChange }: any) => ( + + ), + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , + SelectTrigger: () => null, + SelectValue: () => null, + StandardTooltip: ({ children, content }: any) => {children}, +})) + +const baseModelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, +} + +describe("OpenAI service tier selector", () => { + it("shows supported service tiers and persists the selected tier", () => { + const setApiConfigurationField = vi.fn() + const selectedModelInfo: ModelInfo = { + ...baseModelInfo, + tiers: [ + { name: OpenAiServiceTier.Default, contextWindow: 128_000 }, + { contextWindow: 128_000 }, + { name: OpenAiServiceTier.Flex, contextWindow: 128_000 }, + { name: OpenAiServiceTier.Priority, contextWindow: 128_000 }, + ], + } + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.openaiNative, + openAiNativeApiKey: "test-api-key", + } + + render( + , + ) + + const selector = screen.getByRole("combobox", { name: "Service tier" }) + expect(selector).toHaveValue(OpenAiServiceTier.Default) + expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([ + "Standard", + "Flex", + "Priority", + ]) + + fireEvent.change(selector, { target: { value: OpenAiServiceTier.Flex } }) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiNativeServiceTier", OpenAiServiceTier.Flex) + + fireEvent.change(selector, { target: { value: OpenAiServiceTier.Priority } }) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiNativeServiceTier", OpenAiServiceTier.Priority) + }) + + it("hides the selector when the model only exposes the default tier", () => { + render( + , + ) + + expect(screen.queryByTestId("openai-service-tier")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 9433bb8148..5fca23ba8e 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -8,6 +8,7 @@ import type { Mock } from "vitest" import { ProviderSettings, ModelInfo, + anthropicModels, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, kenariDefaultModelId, @@ -22,6 +23,8 @@ import { vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, + kimiCodeDefaultModelInfo, + providerIdentifiers, } from "@roo-code/types" import { useSelectedModel } from "../useSelectedModel" @@ -87,7 +90,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", openRouterSpecificProvider: "test-provider", } @@ -148,7 +151,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", // This model doesn't exist in available models openRouterSpecificProvider: "test-provider", } @@ -211,7 +214,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", openRouterSpecificProvider: "test-provider", } @@ -263,7 +266,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", } @@ -304,7 +307,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "non-existent-model", openRouterSpecificProvider: "non-existent-provider", } @@ -404,7 +407,7 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(), { wrapper }) - expect(result.current.provider).toBe("openrouter") + expect(result.current.provider).toBe(providerIdentifiers.openrouter) expect(result.current.id).toBe(openRouterDefaultModelId) expect(result.current.info).toBeUndefined() }) @@ -427,7 +430,7 @@ describe("useSelectedModel", () => { it("should apply 1M pricing tier for Claude Sonnet 4.6 when enabled", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-sonnet-4-6", anthropicBeta1MContext: true, } @@ -440,6 +443,39 @@ describe("useSelectedModel", () => { expect(result.current.info?.inputPrice).toBe(6.0) expect(result.current.info?.outputPrice).toBe(22.5) }) + + it("should apply 1M pricing tier for Claude Opus 4.6 when enabled", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-6", + anthropicBeta1MContext: true, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("claude-opus-4-6") + expect(result.current.info?.contextWindow).toBe(1_000_000) + expect(result.current.info?.inputPrice).toBe(10.0) + expect(result.current.info?.outputPrice).toBe(37.5) + }) + + it.each([providerIdentifiers.anthropic, providerIdentifiers.geminiCli, providerIdentifiers.fakeAi] as const)( + "should explicitly resolve configured models for %s", + (apiProvider) => { + const apiConfiguration: ProviderSettings = { + apiProvider, + apiModelId: "claude-sonnet-4-6", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(apiProvider) + expect(result.current.id).toBe("claude-sonnet-4-6") + expect(result.current.info).toEqual(anthropicModels["claude-sonnet-4-6"]) + }, + ) }) describe("bedrock provider with 1M context", () => { @@ -463,7 +499,7 @@ describe("useSelectedModel", () => { it("should enable 1M context window for Bedrock Claude Sonnet 4 when awsBedrock1MContext is true", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsBedrock1MContext: true, } @@ -477,7 +513,7 @@ describe("useSelectedModel", () => { it("should use default context window for Bedrock Claude Sonnet 4 when awsBedrock1MContext is false", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsBedrock1MContext: false, } @@ -491,7 +527,7 @@ describe("useSelectedModel", () => { it("should not affect context window for non-Claude Sonnet 4 Bedrock models", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsBedrock1MContext: true, } @@ -525,7 +561,7 @@ describe("useSelectedModel", () => { it("should enable supportsPromptCache for custom-arn model", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn", } @@ -538,7 +574,7 @@ describe("useSelectedModel", () => { it("should enable supportsImages for custom-arn model", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn", } @@ -571,14 +607,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, litellmModelId: "some-model", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("litellm") + expect(result.current.provider).toBe(providerIdentifiers.litellm) // Should preserve configured model ID since "some-model" doesn't exist in empty litellm models expect(result.current.id).toBe("some-model") // Should use litellmDefaultModelInfo as fallback @@ -597,14 +633,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, // litellmModelId intentionally omitted } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("litellm") + expect(result.current.provider).toBe(providerIdentifiers.litellm) // LiteLLM has no inherent default; with nothing configured the ID is empty rather than a phantom model expect(result.current.id).toBe("") expect(result.current.info).toEqual(litellmDefaultModelInfo) @@ -632,7 +668,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, litellmModelId: "my-custom-model", } @@ -677,14 +713,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, litellmModelId: "non-existing-model", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("litellm") + expect(result.current.provider).toBe(providerIdentifiers.litellm) // Falls back to default model ID expect(result.current.id).toBe("claude-3-7-sonnet-20250219") // Should use litellmDefaultModelInfo as fallback since default model also not in router models @@ -713,14 +749,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, litellmModelId: "custom-model", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("litellm") + expect(result.current.provider).toBe(providerIdentifiers.litellm) expect(result.current.id).toBe("custom-model") expect(result.current.info).toEqual(customModelInfo) }) @@ -758,14 +794,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "kenari", + apiProvider: providerIdentifiers.kenari, kenariModelId: "glm-5-2", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("kenari") + expect(result.current.provider).toBe(providerIdentifiers.kenari) expect(result.current.id).toBe("glm-5-2") expect(result.current.info).toEqual(customModelInfo) }) @@ -783,14 +819,14 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "kenari", + apiProvider: providerIdentifiers.kenari, kenariModelId: "some-model", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("kenari") + expect(result.current.provider).toBe(providerIdentifiers.kenari) // Falls back to the kenari default model ID when the router list is empty expect(result.current.id).toBe(kenariDefaultModelId) // Should use kenariDefaultModelInfo as fallback @@ -819,14 +855,14 @@ describe("useSelectedModel", () => { it("should use openAiModelInfoSaneDefaults when no custom model info is provided", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "gpt-4o", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("openai") + expect(result.current.provider).toBe(providerIdentifiers.openai) expect(result.current.id).toBe("gpt-4o") expect(result.current.info).toEqual(openAiModelInfoSaneDefaults) }) @@ -843,7 +879,7 @@ describe("useSelectedModel", () => { } const apiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "custom-model", openAiCustomModelInfo: customModelInfo, } @@ -851,7 +887,7 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("openai") + expect(result.current.provider).toBe(providerIdentifiers.openai) expect(result.current.id).toBe("custom-model") expect(result.current.info).toEqual(customModelInfo) }) @@ -865,7 +901,7 @@ describe("useSelectedModel", () => { } const apiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "custom-model-no-tools", openAiCustomModelInfo: customModelInfo, } @@ -873,7 +909,7 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("openai") + expect(result.current.provider).toBe(providerIdentifiers.openai) expect(result.current.id).toBe("custom-model-no-tools") expect(result.current.info).toEqual(customModelInfo) }) @@ -900,27 +936,27 @@ describe("useSelectedModel", () => { it("should return default minimax model when no custom model is specified", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "minimax", + apiProvider: providerIdentifiers.minimax, } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("minimax") + expect(result.current.provider).toBe(providerIdentifiers.minimax) expect(result.current.id).toBe(minimaxDefaultModelId) expect(result.current.info).toEqual(minimaxModels[minimaxDefaultModelId]) }) it("should use custom model ID and info when model exists in minimaxModels", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "minimax", + apiProvider: providerIdentifiers.minimax, apiModelId: "MiniMax-M2.7", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("minimax") + expect(result.current.provider).toBe(providerIdentifiers.minimax) expect(result.current.id).toBe("MiniMax-M2.7") expect(result.current.info).toEqual(minimaxModels["MiniMax-M2.7"]) }) @@ -948,14 +984,14 @@ describe("useSelectedModel", () => { it("resolves a listed family's contextWindow to its maxInputTokens", () => { const family = vscodeLlmDefaultModelId const apiConfiguration: ProviderSettings = { - apiProvider: "vscode-lm", + apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector: { vendor: "copilot", family }, } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("vscode-lm") + expect(result.current.provider).toBe(providerIdentifiers.vscodeLm) expect(result.current.id).toBe(`copilot/${family}`) // The bar and the condense gate share one source of truth: contextWindow === maxInputTokens. expect(result.current.info?.contextWindow).toBe(vscodeLlmModels[family].maxInputTokens) @@ -967,14 +1003,14 @@ describe("useSelectedModel", () => { // the advertised window would be caught here. const family = "claude-opus-4.8" const apiConfiguration: ProviderSettings = { - apiProvider: "vscode-lm", + apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector: { vendor: "copilot", family }, } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("vscode-lm") + expect(result.current.provider).toBe(providerIdentifiers.vscodeLm) expect(result.current.id).toBe(`copilot/${family}`) expect(result.current.info?.contextWindow).toBe(vscodeLlmModels[family].maxInputTokens) // 197897 expect(result.current.info?.contextWindow).not.toBe(vscodeLlmModels[family].contextWindow) // NOT 679560 @@ -983,7 +1019,7 @@ describe("useSelectedModel", () => { it("falls back to the default model's window for an unlisted family (NOT 128000)", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "vscode-lm", + apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector: { vendor: "copilot", family: "totally-unknown-family" }, } @@ -1018,32 +1054,76 @@ describe("useSelectedModel", () => { it("should return default Friendli model when no custom model is specified", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "friendli", + apiProvider: providerIdentifiers.friendli, } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("friendli") + expect(result.current.provider).toBe(providerIdentifiers.friendli) expect(result.current.id).toBe(friendliDefaultModelId) expect(result.current.info).toEqual(friendliModels[friendliDefaultModelId]) }) it("should use custom model ID and info when model exists in friendliModels", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "friendli", + apiProvider: providerIdentifiers.friendli, apiModelId: "zai-org/GLM-5.1", } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("friendli") + expect(result.current.provider).toBe(providerIdentifiers.friendli) expect(result.current.id).toBe("zai-org/GLM-5.1") expect(result.current.info).toEqual(friendliModels["zai-org/GLM-5.1"]) }) }) + describe("Kimi Code provider", () => { + it("should resolve the configured model from router models", () => { + const modelInfo: ModelInfo = { + ...kimiCodeDefaultModelInfo, + description: "Configured Kimi Code model", + } + + mockUseRouterModels.mockReturnValue({ + data: { "kimi-code": { "kimi-for-coding": modelInfo } }, + isLoading: false, + isError: false, + } as any) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.kimiCode, + apiModelId: "kimi-for-coding", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.kimiCode) + expect(result.current.id).toBe("kimi-for-coding") + expect(result.current.info).toEqual(modelInfo) + }) + }) + + it("should reject providers unsupported by model selection", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + const preventExpectedError = (event: ErrorEvent) => event.preventDefault() + window.addEventListener("error", preventExpectedError) + const apiConfiguration = { + apiProvider: "unsupported-provider", + } as unknown as ProviderSettings + + const wrapper = createWrapper() + + expect(() => renderHook(() => useSelectedModel(apiConfiguration), { wrapper })).toThrow( + "Unsupported provider: unsupported-provider", + ) + window.removeEventListener("error", preventExpectedError) + consoleError.mockRestore() + }) + describe("moonshot provider", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ @@ -1066,13 +1146,13 @@ describe("useSelectedModel", () => { it("should return default moonshot model when no custom model is specified", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - expect(result.current.provider).toBe("moonshot") + expect(result.current.provider).toBe(providerIdentifiers.moonshot) expect(result.current.id).toBe(moonshotDefaultModelId) expect(result.current.info).toEqual(moonshotModels[moonshotDefaultModelId]) }) @@ -1101,7 +1181,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, } const wrapper = createWrapper() @@ -1114,7 +1194,7 @@ describe("useSelectedModel", () => { it("should fallback to default when model ID is not in static or router models", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, apiModelId: "non-existent-model", } @@ -1127,7 +1207,7 @@ describe("useSelectedModel", () => { it("should use getValidatedModelId to return apiModelId when valid", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, apiModelId: "kimi-k2-turbo-preview", } diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 50886d4538..ec513ce885 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -36,6 +36,7 @@ import { isDynamicProvider, isRetiredProvider, getProviderDefaultModelId, + providerIdentifiers, } from "@roo-code/types" import { useRouterModels } from "./useRouterModels" @@ -148,7 +149,7 @@ function getSelectedModel({ // this gives a better UX than showing the default model const defaultModelId = getProviderDefaultModelId(provider) switch (provider) { - case "openrouter": { + case providerIdentifiers.openrouter: { const id = getValidatedModelId(apiConfiguration.openRouterModelId, routerModels.openrouter, defaultModelId) let info = routerModels.openrouter?.[id] const specificProvider = apiConfiguration.openRouterSpecificProvider @@ -164,17 +165,17 @@ function getSelectedModel({ return { id, info } } - case "requesty": { + case providerIdentifiers.requesty: { const id = getValidatedModelId(apiConfiguration.requestyModelId, routerModels.requesty, defaultModelId) const routerInfo = routerModels.requesty?.[id] return { id, info: routerInfo } } - case "unbound": { + case providerIdentifiers.unbound: { const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId) const routerInfo = routerModels.unbound?.[id] return { id, info: routerInfo } } - case "litellm": { + case providerIdentifiers.litellm: { // When the model list is empty (not yet loaded or still loading), // preserve the configured model ID. LiteLLM is a proxy with no inherent // default model, so we never substitute a hardcoded default here -- when @@ -187,17 +188,17 @@ function getSelectedModel({ const routerInfo = routerModels.litellm?.[id] return { id, info: routerInfo ?? litellmDefaultModelInfo } } - case "xai": { + case providerIdentifiers.xai: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = xaiModels[id as keyof typeof xaiModels] return info ? { id, info } : { id, info: undefined } } - case "baseten": { + case providerIdentifiers.baseten: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = basetenModels[id as keyof typeof basetenModels] return { id, info } } - case "bedrock": { + case providerIdentifiers.bedrock: { const id = apiConfiguration.apiModelId ?? defaultModelId const baseInfo = bedrockModels[id as keyof typeof bedrockModels] @@ -221,7 +222,7 @@ function getSelectedModel({ return { id, info: baseInfo } } - case "vertex": { + case providerIdentifiers.vertex: { const id = apiConfiguration.apiModelId ?? defaultModelId const baseInfo = vertexModels[id as keyof typeof vertexModels] @@ -244,12 +245,12 @@ function getSelectedModel({ return { id, info: baseInfo } } - case "gemini": { + case providerIdentifiers.gemini: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = geminiModels[id as keyof typeof geminiModels] return { id, info } } - case "deepseek": { + case providerIdentifiers.deepseek: { const availableModels = routerModels.deepseek ? { ...deepSeekModels, ...routerModels.deepseek } : deepSeekModels @@ -258,7 +259,7 @@ function getSelectedModel({ const staticInfo = deepSeekModels[id as keyof typeof deepSeekModels] return { id, info: routerInfo ?? staticInfo } } - case "moonshot": { + case providerIdentifiers.moonshot: { const availableModels = routerModels.moonshot ? { ...moonshotModels, ...routerModels.moonshot } : moonshotModels @@ -267,23 +268,23 @@ function getSelectedModel({ const staticInfo = moonshotModels[id as keyof typeof moonshotModels] return { id, info: routerInfo ?? staticInfo } } - case "kimi-code": { + case providerIdentifiers.kimiCode: { const configuredId = apiConfiguration.apiModelId const availableModels = routerModels["kimi-code"] const id = configuredId || defaultModelId return { id, info: availableModels?.[id] ?? kimiCodeDefaultModelInfo } } - case "minimax": { + case providerIdentifiers.minimax: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = minimaxModels[id as keyof typeof minimaxModels] return { id, info } } - case "mimo": { + case providerIdentifiers.mimo: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = mimoModels[id as keyof typeof mimoModels] ?? mimoModels["mimo-v2.5-pro"] return { id, info } } - case "zai": { + case providerIdentifiers.zai: { const isChina = apiConfiguration.zaiApiLine === "china_coding" const models = isChina ? mainlandZAiModels : internationalZAiModels const defaultModelId = getProviderDefaultModelId(provider, { isChina }) @@ -291,23 +292,23 @@ function getSelectedModel({ const info = models[id as keyof typeof models] return { id, info } } - case "openai-native": { + case providerIdentifiers.openaiNative: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = openAiNativeModels[id as keyof typeof openAiNativeModels] return { id, info } } - case "mistral": { + case providerIdentifiers.mistral: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = mistralModels[id as keyof typeof mistralModels] return { id, info } } - case "openai": { + case providerIdentifiers.openai: { const id = apiConfiguration.openAiModelId ?? "" const customInfo = apiConfiguration?.openAiCustomModelInfo const info = customInfo ?? openAiModelInfoSaneDefaults return { id, info } } - case "ollama": { + case providerIdentifiers.ollama: { const id = apiConfiguration.ollamaModelId ?? "" const info = ollamaModels && ollamaModels[apiConfiguration.ollamaModelId!] @@ -323,7 +324,7 @@ function getSelectedModel({ info: adjustedInfo || undefined, } } - case "lmstudio": { + case providerIdentifiers.lmstudio: { const id = apiConfiguration.lmStudioModelId ?? "" const modelInfo = lmStudioModels && lmStudioModels[apiConfiguration.lmStudioModelId!] return { @@ -331,7 +332,7 @@ function getSelectedModel({ info: modelInfo ? { ...lMStudioDefaultModelInfo, ...modelInfo } : undefined, } } - case "vscode-lm": { + case providerIdentifiers.vscodeLm: { const id = apiConfiguration?.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` : vscodeLlmDefaultModelId @@ -351,37 +352,37 @@ function getSelectedModel({ } return { id, info } } - case "sambanova": { + case providerIdentifiers.sambanova: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = sambaNovaModels[id as keyof typeof sambaNovaModels] return { id, info } } - case "fireworks": { + case providerIdentifiers.fireworks: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = fireworksModels[id as keyof typeof fireworksModels] return { id, info } } - case "friendli": { + case providerIdentifiers.friendli: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = friendliModels[id as keyof typeof friendliModels] return { id, info } } - case "poe": { + case providerIdentifiers.poe: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = routerModels.poe?.[id] return { id, info } } - case "qwen-code": { + case providerIdentifiers.qwenCode: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = qwenCodeModels[id as keyof typeof qwenCodeModels] return { id, info } } - case "openai-codex": { + case providerIdentifiers.openaiCodex: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = openAiCodexModels[id as keyof typeof openAiCodexModels] return { id, info } } - case "vercel-ai-gateway": { + case providerIdentifiers.vercelAiGateway: { const id = getValidatedModelId( apiConfiguration.vercelAiGatewayModelId, routerModels["vercel-ai-gateway"], @@ -390,7 +391,7 @@ function getSelectedModel({ const info = routerModels["vercel-ai-gateway"]?.[id] return { id, info } } - case "opencode-go": { + case providerIdentifiers.opencodeGo: { const id = getValidatedModelId( apiConfiguration.opencodeGoModelId, routerModels["opencode-go"], @@ -401,14 +402,14 @@ function getSelectedModel({ const info = routerModels["opencode-go"]?.[id] ?? opencodeGoDefaultModelInfo return { id, info } } - case "kenari": { + case providerIdentifiers.kenari: { const id = getValidatedModelId(apiConfiguration.kenariModelId, routerModels["kenari"], defaultModelId) // Fall back to the provider's default ModelInfo so capability-driven UI // keeps working when the /models list is empty or unavailable. const info = routerModels["kenari"]?.[id] ?? kenariDefaultModelInfo return { id, info } } - case "zoo-gateway": { + case providerIdentifiers.zooGateway: { const id = getValidatedModelId( apiConfiguration.zooGatewayModelId, routerModels["zoo-gateway"], @@ -417,16 +418,15 @@ function getSelectedModel({ const info = routerModels["zoo-gateway"]?.[id] return { id, info } } - // case "anthropic": - // case "fake-ai": - default: { - provider satisfies "anthropic" | "gemini-cli" | "fake-ai" + case providerIdentifiers.anthropic: + case providerIdentifiers.geminiCli: + case providerIdentifiers.fakeAi: { const id = apiConfiguration.apiModelId ?? defaultModelId const baseInfo = anthropicModels[id as keyof typeof anthropicModels] // Apply 1M context beta tier pricing for supported Claude 4 models if ( - provider === "anthropic" && + provider === providerIdentifiers.anthropic && (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-sonnet-4-6" || @@ -461,5 +461,9 @@ function getSelectedModel({ return { id, info: baseInfo } } + default: { + provider satisfies never + throw new Error(`Unsupported provider: ${provider}`) + } } } diff --git a/webview-ui/src/components/welcome/__tests__/__screenshots__/zoo-hero-dark.png b/webview-ui/src/components/welcome/__tests__/__screenshots__/zoo-hero-dark.png index 6f5e11eaf9..3a99cfb487 100644 Binary files a/webview-ui/src/components/welcome/__tests__/__screenshots__/zoo-hero-dark.png and b/webview-ui/src/components/welcome/__tests__/__screenshots__/zoo-hero-dark.png differ diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index d4e5e2524e..11ac1d4028 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -187,7 +187,7 @@ "current": "Actual" }, "instructions": { - "wantsToFetch": "Roo vol obtenir instruccions detallades per ajudar amb la tasca actual." + "wantsToFetch": "Zoo vol obtenir instruccions detallades per ajudar amb la tasca actual." }, "fileOperations": { "wantsToRead": "Zoo vol llegir aquest fitxer", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo vol editar aquest fitxer fora de l'espai de treball", "wantsToEditProtected": "Zoo vol editar un fitxer de configuració protegit", "wantsToCreate": "Zoo vol crear un nou fitxer", - "wantsToSearchReplace": "Roo vol realitzar cerca i substitució en aquest fitxer", - "didSearchReplace": "Roo ha realitzat cerca i substitució en aquest fitxer", + "wantsToSearchReplace": "Zoo vol realitzar cerca i substitució en aquest fitxer", + "didSearchReplace": "Zoo ha realitzat cerca i substitució en aquest fitxer", "wantsToInsert": "Zoo vol inserir contingut en aquest fitxer", "wantsToInsertWithLineNumber": "Zoo vol inserir contingut a la línia {{lineNumber}} d'aquest fitxer", "wantsToInsertAtEnd": "Zoo vol afegir contingut al final d'aquest fitxer", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f861b8734c..b9644e0d12 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Pots configurar una drecera global per a aquesta configuració a les preferències del teu IDE.", - "description": "Permet que Roo realitzi operacions automàticament sense requerir aprovació. Activeu aquesta configuració només si confieu plenament en la IA i enteneu els riscos de seguretat associats.", + "description": "Permet que Zoo realitzi operacions automàticament sense requerir aprovació. Activeu aquesta configuració només si confieu plenament en la IA i enteneu els riscos de seguretat associats.", "enabled": "Auto-aprovació activada", "toggleAriaLabel": "Commuta l'aprovació automàtica", "disabledAriaLabel": "Aprovació automàtica desactivada: seleccioneu primer les opcions", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index fb63e5b6a1..38415c87a5 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -130,7 +130,7 @@ "title": "Modi", "marketplace": "Modus-Marketplace", "settings": "Modus-Einstellungen", - "description": "Spezialisierte Personas, die Roos Verhalten anpassen.", + "description": "Spezialisierte Personas, die Zoos Verhalten anpassen.", "searchPlaceholder": "Modi suchen...", "noResults": "Keine Ergebnisse gefunden" }, @@ -187,7 +187,7 @@ "current": "Aktuell" }, "instructions": { - "wantsToFetch": "Roo möchte detaillierte Anweisungen abrufen, um bei der aktuellen Aufgabe zu helfen" + "wantsToFetch": "Zoo möchte detaillierte Anweisungen abrufen, um bei der aktuellen Aufgabe zu helfen" }, "fileOperations": { "wantsToRead": "Zoo möchte diese Datei lesen", @@ -198,8 +198,8 @@ "wantsToEditOutsideWorkspace": "Zoo möchte diese Datei außerhalb des Arbeitsbereichs bearbeiten", "wantsToEditProtected": "Zoo möchte eine geschützte Konfigurationsdatei bearbeiten", "wantsToCreate": "Zoo möchte eine neue Datei erstellen", - "wantsToSearchReplace": "Roo möchte in dieser Datei suchen und ersetzen", - "didSearchReplace": "Roo hat Suchen und Ersetzen in dieser Datei durchgeführt", + "wantsToSearchReplace": "Zoo möchte in dieser Datei suchen und ersetzen", + "didSearchReplace": "Zoo hat Suchen und Ersetzen in dieser Datei durchgeführt", "wantsToInsert": "Zoo möchte Inhalte in diese Datei einfügen", "wantsToInsertWithLineNumber": "Zoo möchte Inhalte in diese Datei in Zeile {{lineNumber}} einfügen", "wantsToInsertAtEnd": "Zoo möchte Inhalte am Ende dieser Datei anhängen", @@ -288,7 +288,7 @@ "copyToClipboard": "In Zwischenablage kopieren", "copied": "Kopiert!", "diagnostics": "Detaillierte Fehlerinformationen abrufen", - "proxyProvider": "Du scheinst einen Proxy-basierten Anbieter zu verwenden. Überprüfe unbedingt seine Protokolle und stelle sicher, dass er Roos Anfragen nicht umschreibt." + "proxyProvider": "Du scheinst einen Proxy-basierten Anbieter zu verwenden. Überprüfe unbedingt seine Protokolle und stelle sicher, dass er Zoos Anfragen nicht umschreibt." }, "powershell": { "issues": "Es scheint, dass du Probleme mit Windows PowerShell hast, bitte sieh dir dies an" diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 551c217125..28f7cbec5f 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -9,7 +9,7 @@ "editModesConfig": "Moduskonfiguration bearbeiten", "editGlobalModes": "Globale Modi bearbeiten", "editProjectModes": "Projektmodi bearbeiten (.roomodes)", - "createModeHelpText": "Modi sind spezialisierte Personas, die Roos Verhalten anpassen. <0>Erfahre mehr über die Verwendung von Modi oder <1>die Anpassung von Modi.", + "createModeHelpText": "Modi sind spezialisierte Personas, die Zoos Verhalten anpassen. <0>Erfahre mehr über die Verwendung von Modi oder <1>die Anpassung von Modi.", "selectMode": "Modi suchen" }, "apiConfiguration": { @@ -33,7 +33,7 @@ "roleDefinition": { "title": "Rollendefinition", "resetToDefault": "Auf Standardwerte zurücksetzen", - "description": "Definiere Roos Expertise und Persönlichkeit für diesen Modus. Diese Beschreibung prägt, wie Zoo sich präsentiert und an Aufgaben herangeht." + "description": "Definiere Zoos Expertise und Persönlichkeit für diesen Modus. Diese Beschreibung prägt, wie Zoo sich präsentiert und an Aufgaben herangeht." }, "description": { "title": "Kurzbeschreibung (für Menschen)", @@ -167,7 +167,7 @@ }, "roleDefinition": { "label": "Rollendefinition", - "description": "Definiere Roos Expertise und Persönlichkeit für diesen Modus." + "description": "Definiere Zoos Expertise und Persönlichkeit für diesen Modus." }, "whenToUse": { "label": "Wann zu verwenden (optional)", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index e1ff092237..46a8bd28e4 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Du kannst in deinen IDE-Einstellungen einen globalen Shortcut für diese Einstellung konfigurieren.", - "description": "Erlaubt Roo, Operationen automatisch ohne Genehmigung durchzuführen. Aktiviere diese Einstellungen nur, wenn du der KI vollständig vertraust und die damit verbundenen Sicherheitsrisiken verstehst.", + "description": "Erlaubt Zoo, Operationen automatisch ohne Genehmigung durchzuführen. Aktiviere diese Einstellungen nur, wenn du der KI vollständig vertraust und die damit verbundenen Sicherheitsrisiken verstehst.", "enabled": "Auto-Genehmigung aktiviert", "toggleAriaLabel": "Automatische Genehmigung umschalten", "disabledAriaLabel": "Automatische Genehmigung deaktiviert - zuerst Optionen auswählen", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 1a297de709..d3beb38fe0 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -187,7 +187,7 @@ "current": "Actual" }, "instructions": { - "wantsToFetch": "Roo quiere obtener instrucciones detalladas para ayudar con la tarea actual" + "wantsToFetch": "Zoo quiere obtener instrucciones detalladas para ayudar con la tarea actual" }, "fileOperations": { "wantsToRead": "Zoo quiere leer este archivo", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo quiere editar este archivo fuera del espacio de trabajo", "wantsToEditProtected": "Zoo quiere editar un archivo de configuración protegido", "wantsToCreate": "Zoo quiere crear un nuevo archivo", - "wantsToSearchReplace": "Roo quiere realizar búsqueda y reemplazo en este archivo", - "didSearchReplace": "Roo realizó búsqueda y reemplazo en este archivo", + "wantsToSearchReplace": "Zoo quiere realizar búsqueda y reemplazo en este archivo", + "didSearchReplace": "Zoo realizó búsqueda y reemplazo en este archivo", "wantsToInsert": "Zoo quiere insertar contenido en este archivo", "wantsToInsertWithLineNumber": "Zoo quiere insertar contenido en este archivo en la línea {{lineNumber}}", "wantsToInsertAtEnd": "Zoo quiere añadir contenido al final de este archivo", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 4a5ce59d79..f9c1be76f4 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Puedes configurar un atajo global para esta configuración en las preferencias de tu IDE.", - "description": "Permitir que Roo realice operaciones automáticamente sin requerir aprobación. Habilite esta configuración solo si confía plenamente en la IA y comprende los riesgos de seguridad asociados.", + "description": "Permitir que Zoo realice operaciones automáticamente sin requerir aprobación. Habilite esta configuración solo si confía plenamente en la IA y comprende los riesgos de seguridad asociados.", "enabled": "Auto-aprobación habilitada", "toggleAriaLabel": "Alternar aprobación automática", "disabledAriaLabel": "Aprobación automática desactivada: seleccione primero las opciones", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 24d27418e7..080e3cb044 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -194,8 +194,8 @@ "wantsToEditOutsideWorkspace": "Zoo veut éditer ce fichier en dehors de l'espace de travail", "wantsToEditProtected": "Zoo veut éditer un fichier de configuration protégé", "wantsToCreate": "Zoo veut créer un nouveau fichier", - "wantsToSearchReplace": "Roo veut effectuer une recherche et remplacement sur ce fichier", - "didSearchReplace": "Roo a effectué une recherche et remplacement sur ce fichier", + "wantsToSearchReplace": "Zoo veut effectuer une recherche et remplacement sur ce fichier", + "didSearchReplace": "Zoo a effectué une recherche et remplacement sur ce fichier", "wantsToInsert": "Zoo veut insérer du contenu dans ce fichier", "wantsToInsertWithLineNumber": "Zoo veut insérer du contenu dans ce fichier à la ligne {{lineNumber}}", "wantsToInsertAtEnd": "Zoo veut ajouter du contenu à la fin de ce fichier", @@ -208,7 +208,7 @@ "didGenerateImage": "Zoo a généré une image" }, "instructions": { - "wantsToFetch": "Roo veut récupérer des instructions détaillées pour aider à la tâche actuelle" + "wantsToFetch": "Zoo veut récupérer des instructions détaillées pour aider à la tâche actuelle" }, "directoryOperations": { "wantsToViewTopLevel": "Zoo veut voir les fichiers de premier niveau dans ce répertoire", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 03c64cc1e9..6da211144b 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Vous pouvez configurer un raccourci global pour ce paramètre dans les préférences de votre IDE.", - "description": "Permettre à Roo d'effectuer automatiquement des opérations sans requérir d'approbation. Activez ces paramètres uniquement si vous faites entièrement confiance à l'IA et que vous comprenez les risques de sécurité associés.", + "description": "Permettre à Zoo d'effectuer automatiquement des opérations sans requérir d'approbation. Activez ces paramètres uniquement si vous faites entièrement confiance à l'IA et que vous comprenez les risques de sécurité associés.", "enabled": "Auto-approbation activée", "toggleAriaLabel": "Activer/désactiver l'approbation automatique", "disabledAriaLabel": "Approbation automatique désactivée - sélectionnez d'abord les options", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 65306b39e8..f2ac1cb20d 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -187,7 +187,7 @@ "current": "वर्तमान" }, "instructions": { - "wantsToFetch": "Roo को वर्तमान कार्य में सहायता के लिए विस्तृत निर्देश प्राप्त करना है" + "wantsToFetch": "Zoo को वर्तमान कार्य में सहायता के लिए विस्तृत निर्देश प्राप्त करना है" }, "fileOperations": { "wantsToRead": "Zoo इस फ़ाइल को पढ़ना चाहता है", @@ -197,12 +197,12 @@ "wantsToEditOutsideWorkspace": "Zoo कार्यक्षेत्र के बाहर इस फ़ाइल को संपादित करना चाहता है", "wantsToEditProtected": "Zoo एक सुरक्षित कॉन्फ़िगरेशन फ़ाइल को संपादित करना चाहता है", "wantsToCreate": "Zoo एक नई फ़ाइल बनाना चाहता है", - "wantsToSearchReplace": "Roo इस फ़ाइल में खोज और प्रतिस्थापन करना चाहता है", - "didSearchReplace": "Roo ने इस फ़ाइल में खोज और प्रतिस्थापन किया", + "wantsToSearchReplace": "Zoo इस फ़ाइल में खोज और प्रतिस्थापन करना चाहता है", + "didSearchReplace": "Zoo ने इस फ़ाइल में खोज और प्रतिस्थापन किया", "wantsToInsert": "Zoo इस फ़ाइल में सामग्री डालना चाहता है", "wantsToInsertWithLineNumber": "Zoo इस फ़ाइल की {{lineNumber}} लाइन पर सामग्री डालना चाहता है", "wantsToInsertAtEnd": "Zoo इस फ़ाइल के अंत में सामग्री जोड़ना चाहता है", - "wantsToReadAndXMore": "रू इस फ़ाइल को और {{count}} अन्य को पढ़ना चाहता है", + "wantsToReadAndXMore": "Zoo इस फ़ाइल को और {{count}} अन्य को पढ़ना चाहता है", "wantsToReadMultiple": "Zoo कई फ़ाइलें पढ़ना चाहता है", "wantsToApplyBatchChanges": "Zoo कई फ़ाइलों में परिवर्तन लागू करना चाहता है", "wantsToGenerateImage": "Zoo एक छवि बनाना चाहता है", @@ -246,7 +246,7 @@ "response": "प्रतिक्रिया", "arguments": "आर्ग्युमेंट्स", "text": { - "rooSaid": "रू ने कहा" + "rooSaid": "Zoo ने कहा" }, "feedback": { "youSaid": "आपने कहा" diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2f6c6fc542..522c68a586 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "आप अपनी आईडीई वरीयताओं में इस सेटिंग के लिए एक वैश्विक शॉर्टकट कॉन्फ़िगर कर सकते हैं।", - "description": "Roo को अनुमोदन की आवश्यकता के बिना स्वचालित रूप से ऑपरेशन करने की अनुमति दें। इन सेटिंग्स को केवल तभी सक्षम करें जब आप AI पर पूरी तरह से भरोसा करते हों और संबंधित सुरक्षा जोखिमों को समझते हों।", + "description": "Zoo को अनुमोदन की आवश्यकता के बिना स्वचालित रूप से ऑपरेशन करने की अनुमति दें। इन सेटिंग्स को केवल तभी सक्षम करें जब आप AI पर पूरी तरह से भरोसा करते हों और संबंधित सुरक्षा जोखिमों को समझते हों।", "enabled": "स्वत:-अनुमोदन सक्षम", "toggleAriaLabel": "स्वतः-अनुमोदन टॉगल करें", "disabledAriaLabel": "स्वतः-अनुमोदन अक्षम - पहले विकल्प चुनें", @@ -616,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "त्रुटि और पुनरावृत्ति सीमा", - "description": "'रू को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या। इस सुरक्षा तंत्र को अक्षम करने के लिए 0 पर सेट करें (यह कभी ट्रिगर नहीं होगा)।", + "description": "'Zoo को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या। इस सुरक्षा तंत्र को अक्षम करने के लिए 0 पर सेट करें (यह कभी ट्रिगर नहीं होगा)।", "unlimitedDescription": "असीमित पुनः प्रयास सक्षम (स्वतः आगे बढ़ें)। संवाद कभी नहीं दिखाई देगा।", "warning": "⚠️ 0 पर सेट करने से असीमित पुनः प्रयास की अनुमति मिलती है जिससे महत्वपूर्ण एपीआई उपयोग हो सकता है" }, diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index fb11881cb4..e95a517221 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -231,7 +231,7 @@ "didLoad": "Zoo telah memuat keterampilan" }, "instructions": { - "wantsToFetch": "Roo ingin mengambil instruksi detail untuk membantu tugas saat ini" + "wantsToFetch": "Zoo ingin mengambil instruksi detail untuk membantu tugas saat ini" }, "fileOperations": { "wantsToRead": "Zoo ingin membaca file ini", @@ -248,8 +248,8 @@ "wantsToGenerateImageProtected": "Zoo ingin menghasilkan gambar di lokasi yang dilindungi", "didGenerateImage": "Zoo telah menghasilkan gambar", "wantsToCreate": "Zoo ingin membuat file baru", - "wantsToSearchReplace": "Roo ingin mencari dan mengganti di file ini", - "didSearchReplace": "Roo melakukan pencarian dan penggantian pada file ini", + "wantsToSearchReplace": "Zoo ingin mencari dan mengganti di file ini", + "didSearchReplace": "Zoo melakukan pencarian dan penggantian pada file ini", "wantsToInsert": "Zoo ingin menyisipkan konten ke file ini", "wantsToInsertWithLineNumber": "Zoo ingin menyisipkan konten ke file ini di baris {{lineNumber}}", "wantsToInsertAtEnd": "Zoo ingin menambahkan konten ke akhir file ini" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index f98af2bc28..b2943e63bc 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Anda dapat mengonfigurasi pintasan global untuk pengaturan ini di preferensi IDE Anda.", - "description": "Izinkan Roo untuk secara otomatis melakukan operasi tanpa memerlukan persetujuan. Aktifkan pengaturan ini hanya jika kamu sepenuhnya mempercayai AI dan memahami risiko keamanan yang terkait.", + "description": "Izinkan Zoo untuk secara otomatis melakukan operasi tanpa memerlukan persetujuan. Aktifkan pengaturan ini hanya jika kamu sepenuhnya mempercayai AI dan memahami risiko keamanan yang terkait.", "enabled": "Auto-Approve Diaktifkan", "toggleAriaLabel": "Beralih persetujuan otomatis", "disabledAriaLabel": "Persetujuan otomatis dinonaktifkan - pilih opsi terlebih dahulu", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index d6917d1be0..7085edee5b 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -141,7 +141,7 @@ "forNextMode": "per la prossima modalità", "forPreviousMode": "per la modalità precedente", "instructions": { - "wantsToFetch": "Roo vuole recuperare istruzioni dettagliate per aiutare con l'attività corrente" + "wantsToFetch": "Zoo vuole recuperare istruzioni dettagliate per aiutare con l'attività corrente" }, "error": "Errore", "diffError": { @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo vuole modificare questo file al di fuori dell'area di lavoro", "wantsToEditProtected": "Zoo vuole modificare un file di configurazione protetto", "wantsToCreate": "Zoo vuole creare un nuovo file", - "wantsToSearchReplace": "Roo vuole eseguire ricerca e sostituzione in questo file", - "didSearchReplace": "Roo ha eseguito ricerca e sostituzione in questo file", + "wantsToSearchReplace": "Zoo vuole eseguire ricerca e sostituzione in questo file", + "didSearchReplace": "Zoo ha eseguito ricerca e sostituzione in questo file", "wantsToInsert": "Zoo vuole inserire contenuto in questo file", "wantsToInsertWithLineNumber": "Zoo vuole inserire contenuto in questo file alla riga {{lineNumber}}", "wantsToInsertAtEnd": "Zoo vuole aggiungere contenuto alla fine di questo file", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index ea66a2ad23..6bd638c796 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Puoi configurare una scorciatoia globale per questa impostazione nelle preferenze del tuo IDE.", - "description": "Permetti a Roo di eseguire automaticamente operazioni senza richiedere approvazione. Abilita queste impostazioni solo se ti fidi completamente dell'IA e comprendi i rischi di sicurezza associati.", + "description": "Permetti a Zoo di eseguire automaticamente operazioni senza richiedere approvazione. Abilita queste impostazioni solo se ti fidi completamente dell'IA e comprendi i rischi di sicurezza associati.", "enabled": "Auto-approvazione abilitata", "toggleAriaLabel": "Attiva/disattiva approvazione automatica", "disabledAriaLabel": "Approvazione automatica disabilitata - seleziona prima le opzioni", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 17665a3754..8abbe5792c 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -130,7 +130,7 @@ "title": "モード", "marketplace": "モードマーケットプレイス", "settings": "モード設定", - "description": "Rooの動作をカスタマイズする専門的なペルソナ。", + "description": "Zooの動作をカスタマイズする専門的なペルソナ。", "searchPlaceholder": "モードを検索...", "noResults": "結果が見つかりません" }, @@ -144,7 +144,7 @@ "diffError": { "title": "編集に失敗しました" }, - "troubleMessage": "Rooに問題が発生しています...", + "troubleMessage": "Zooに問題が発生しています...", "apiRequest": { "title": "APIリクエスト", "failed": "APIリクエスト失敗", @@ -187,42 +187,42 @@ "current": "現在" }, "instructions": { - "wantsToFetch": "Rooは現在のタスクを支援するための詳細な指示を取得したい" + "wantsToFetch": "Zooは現在のタスクを支援するための詳細な指示を取得したい" }, "fileOperations": { - "wantsToRead": "Rooはこのファイルを読みたい", - "wantsToReadOutsideWorkspace": "Rooはワークスペース外のこのファイルを読みたい", - "didRead": "Rooはこのファイルを読みました", - "wantsToEdit": "Rooはこのファイルを編集したい", - "wantsToEditOutsideWorkspace": "Rooはワークスペース外のこのファイルを編集したい", - "wantsToEditProtected": "Rooは保護された設定ファイルを編集したい", - "wantsToCreate": "Rooは新しいファイルを作成したい", - "wantsToSearchReplace": "Rooはこのファイルで検索と置換を行う", - "didSearchReplace": "Rooはこのファイルで検索と置換を実行しました", - "wantsToInsert": "Rooはこのファイルにコンテンツを挿入したい", - "wantsToInsertWithLineNumber": "Rooはこのファイルの{{lineNumber}}行目にコンテンツを挿入したい", - "wantsToInsertAtEnd": "Rooはこのファイルの末尾にコンテンツを追加したい", + "wantsToRead": "Zooはこのファイルを読みたい", + "wantsToReadOutsideWorkspace": "Zooはワークスペース外のこのファイルを読みたい", + "didRead": "Zooはこのファイルを読みました", + "wantsToEdit": "Zooはこのファイルを編集したい", + "wantsToEditOutsideWorkspace": "Zooはワークスペース外のこのファイルを編集したい", + "wantsToEditProtected": "Zooは保護された設定ファイルを編集したい", + "wantsToCreate": "Zooは新しいファイルを作成したい", + "wantsToSearchReplace": "Zooはこのファイルで検索と置換を行う", + "didSearchReplace": "Zooはこのファイルで検索と置換を実行しました", + "wantsToInsert": "Zooはこのファイルにコンテンツを挿入したい", + "wantsToInsertWithLineNumber": "Zooはこのファイルの{{lineNumber}}行目にコンテンツを挿入したい", + "wantsToInsertAtEnd": "Zooはこのファイルの末尾にコンテンツを追加したい", "wantsToReadAndXMore": "Zoo はこのファイルと他に {{count}} 個のファイルを読み込もうとしています", - "wantsToReadMultiple": "Rooは複数のファイルを読み取ろうとしています", - "wantsToApplyBatchChanges": "Rooは複数のファイルに変更を適用したい", - "wantsToGenerateImage": "Rooは画像を生成したい", - "wantsToGenerateImageOutsideWorkspace": "Rooはワークスペース外で画像を生成したい", - "wantsToGenerateImageProtected": "Rooは保護された場所で画像を生成したい", - "didGenerateImage": "Rooは画像を生成しました" + "wantsToReadMultiple": "Zooは複数のファイルを読み取ろうとしています", + "wantsToApplyBatchChanges": "Zooは複数のファイルに変更を適用したい", + "wantsToGenerateImage": "Zooは画像を生成したい", + "wantsToGenerateImageOutsideWorkspace": "Zooはワークスペース外で画像を生成したい", + "wantsToGenerateImageProtected": "Zooは保護された場所で画像を生成したい", + "didGenerateImage": "Zooは画像を生成しました" }, "directoryOperations": { - "wantsToViewTopLevel": "Rooはこのディレクトリのトップレベルファイルを表示したい", - "didViewTopLevel": "Rooはこのディレクトリのトップレベルファイルを表示しました", - "wantsToViewRecursive": "Rooはこのディレクトリのすべてのファイルを再帰的に表示したい", - "didViewRecursive": "Rooはこのディレクトリのすべてのファイルを再帰的に表示しました", - "wantsToSearch": "Rooはこのディレクトリで {{regex}} を検索したい", - "didSearch": "Rooはこのディレクトリで {{regex}} を検索しました", - "wantsToSearchOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で {{regex}} を検索したい", - "didSearchOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で {{regex}} を検索しました", - "wantsToViewTopLevelOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示したい", - "didViewTopLevelOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示しました", - "wantsToViewRecursiveOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示したい", - "didViewRecursiveOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示しました", + "wantsToViewTopLevel": "Zooはこのディレクトリのトップレベルファイルを表示したい", + "didViewTopLevel": "Zooはこのディレクトリのトップレベルファイルを表示しました", + "wantsToViewRecursive": "Zooはこのディレクトリのすべてのファイルを再帰的に表示したい", + "didViewRecursive": "Zooはこのディレクトリのすべてのファイルを再帰的に表示しました", + "wantsToSearch": "Zooはこのディレクトリで {{regex}} を検索したい", + "didSearch": "Zooはこのディレクトリで {{regex}} を検索しました", + "wantsToSearchOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)で {{regex}} を検索したい", + "didSearchOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)で {{regex}} を検索しました", + "wantsToViewTopLevelOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示したい", + "didViewTopLevelOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示しました", + "wantsToViewRecursiveOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示したい", + "didViewRecursiveOutsideWorkspace": "Zooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示しました", "wantsToViewMultipleDirectories": "Zoo は複数のディレクトリを表示したい" }, "commandOutput": "コマンド出力", @@ -246,24 +246,24 @@ "response": "応答", "arguments": "引数", "text": { - "rooSaid": "Rooの発言" + "rooSaid": "Zooの発言" }, "feedback": { "youSaid": "あなたの発言" }, "mcp": { - "wantsToUseTool": "RooはMCPサーバー{{serverName}}でツールを使用したい", - "wantsToAccessResource": "RooはMCPサーバー{{serverName}}のリソースにアクセスしたい" + "wantsToUseTool": "ZooはMCPサーバー{{serverName}}でツールを使用したい", + "wantsToAccessResource": "ZooはMCPサーバー{{serverName}}のリソースにアクセスしたい" }, "modes": { - "wantsToSwitch": "Rooは{{mode}}モードに切り替えたい", - "wantsToSwitchWithReason": "Rooは次の理由で{{mode}}モードに切り替えたい: {{reason}}", - "didSwitch": "Rooは{{mode}}モードに切り替えました", - "didSwitchWithReason": "Rooは次の理由で{{mode}}モードに切り替えました: {{reason}}" + "wantsToSwitch": "Zooは{{mode}}モードに切り替えたい", + "wantsToSwitchWithReason": "Zooは次の理由で{{mode}}モードに切り替えたい: {{reason}}", + "didSwitch": "Zooは{{mode}}モードに切り替えました", + "didSwitchWithReason": "Zooは次の理由で{{mode}}モードに切り替えました: {{reason}}" }, "subtasks": { - "wantsToCreate": "Rooは{{mode}}モードで新しいサブタスクを作成したい", - "wantsToFinish": "Rooはこのサブタスクを終了したい", + "wantsToCreate": "Zooは{{mode}}モードで新しいサブタスクを作成したい", + "wantsToFinish": "Zooはこのサブタスクを終了したい", "newTaskContent": "サブタスク指示", "completionContent": "サブタスク完了", "resultContent": "サブタスク結果", @@ -272,7 +272,7 @@ "goToSubtask": "タスクを表示" }, "questions": { - "hasQuestion": "Rooは質問があります" + "hasQuestion": "Zooは質問があります" }, "taskCompleted": "タスク完了", "modelResponseIncomplete": "モデルの応答が不完全", @@ -288,7 +288,7 @@ "copyToClipboard": "クリップボードにコピー", "copied": "コピーしました!", "diagnostics": "詳細なエラー情報を取得", - "proxyProvider": "プロキシベースのプロバイダーを使用しているようです。ログを確認し、Rooのリクエストを書き換えていないことを確認してください。" + "proxyProvider": "プロキシベースのプロバイダーを使用しているようです。ログを確認し、Zooのリクエストを書き換えていないことを確認してください。" }, "powershell": { "issues": "Windows PowerShellに問題があるようです。こちらを参照してください" @@ -331,8 +331,8 @@ } }, "skill": { - "wantsToLoad": "Rooはスキルを読み込もうとしています", - "didLoad": "Rooはスキルを読み込みました" + "wantsToLoad": "Zooはスキルを読み込もうとしています", + "didLoad": "Zooはスキルを読み込みました" }, "followUpSuggest": { "copyToInput": "入力欄にコピー(またはShift + クリック)", @@ -375,18 +375,18 @@ "ask": { "autoApprovedRequestLimitReached": { "title": "自動承認リクエスト制限に達しました", - "description": "Rooは{{count}}件のAPI自動承認リクエスト制限に達しました。カウントをリセットしてタスクを続行しますか?", + "description": "Zooは{{count}}件のAPI自動承認リクエスト制限に達しました。カウントをリセットしてタスクを続行しますか?", "button": "リセットして続行" }, "autoApprovedCostLimitReached": { "title": "自動承認コスト制限に達しました", - "description": "Rooは自動承認されたコスト制限の${{count}}に達しました。コストをリセットしてタスクを続行しますか?", + "description": "Zooは自動承認されたコスト制限の${{count}}に達しました。コストをリセットしてタスクを続行しますか?", "button": "リセットして続ける" } }, "codebaseSearch": { - "wantsToSearch": "Rooはコードベースで {{query}} を検索したい", - "wantsToSearchWithPath": "Rooは {{path}} 内のコードベースで {{query}} を検索したい", + "wantsToSearch": "Zooはコードベースで {{query}} を検索したい", + "wantsToSearchWithPath": "Zooは {{path}} 内のコードベースで {{query}} を検索したい", "didSearch_one": "1件の結果が見つかりました", "didSearch_other": "{{count}}件の結果が見つかりました", "resultTooltip": "類似度スコア: {{score}} (クリックしてファイルを開く)" @@ -468,8 +468,8 @@ "clickToEdit": "クリックしてメッセージを編集" }, "slashCommand": { - "wantsToRun": "Rooはスラッシュコマンドを実行したい", - "didRun": "Rooはスラッシュコマンドを実行しました" + "wantsToRun": "Zooはスラッシュコマンドを実行したい", + "didRun": "Zooはスラッシュコマンドを実行しました" }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", @@ -488,7 +488,7 @@ "openMcpSettings": "MCP 設定を開く" }, "readCommandOutput": { - "title": "Rooがコマンド出力を読み込みました" + "title": "Zooがコマンド出力を読み込みました" }, "retiredProvider": { "title": "プロバイダーのサポート終了", diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index 4d7c77e688..eb1b1af251 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -9,7 +9,7 @@ "editModesConfig": "モード設定を編集", "editGlobalModes": "グローバルモードを編集", "editProjectModes": "プロジェクトモードを編集 (.roomodes)", - "createModeHelpText": "モードはRooの振る舞いを調整する専門的なペルソナです。<0>モードの使用について学ぶか、<1>モードのカスタマイズについて学ぶ。", + "createModeHelpText": "モードはZooの振る舞いを調整する専門的なペルソナです。<0>モードの使用について学ぶか、<1>モードのカスタマイズについて学ぶ。", "selectMode": "モードを検索" }, "apiConfiguration": { @@ -33,7 +33,7 @@ "roleDefinition": { "title": "役割の定義", "resetToDefault": "デフォルトにリセット", - "description": "このモードのRooの専門知識と個性を定義します。この説明は、Rooが自身をどのように表現し、タスクにどのように取り組むかを形作ります。" + "description": "このモードのZooの専門知識と個性を定義します。この説明は、Zooが自身をどのように表現し、タスクにどのように取り組むかを形作ります。" }, "description": { "title": "短い説明(人間向け)", @@ -102,7 +102,7 @@ "types": { "ENHANCE": { "label": "プロンプトを強化", - "description": "プロンプト強化を使用して、入力に合わせたカスタマイズされた提案や改善を得ることができます。これにより、Rooがあなたの意図を理解し、最適な回答を提供できます。チャットの✨アイコンから利用できます。" + "description": "プロンプト強化を使用して、入力に合わせたカスタマイズされた提案や改善を得ることができます。これにより、Zooがあなたの意図を理解し、最適な回答を提供できます。チャットの✨アイコンから利用できます。" }, "CONDENSE": { "label": "コンテキスト圧縮", @@ -167,7 +167,7 @@ }, "roleDefinition": { "label": "役割の定義", - "description": "このモードのRooの専門知識と個性を定義します。" + "description": "このモードのZooの専門知識と個性を定義します。" }, "whenToUse": { "label": "使用タイミング(オプション)", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index eed8315620..96fba3521a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -165,7 +165,7 @@ "footer": "ルールは一般ガイダンスの前にモード固有ガイダンスを読み込みます。グローバルルールはワークスペースルールより先に読み込まれます。" }, "prompts": { - "description": "プロンプトの強化、コードの説明、問題の修正などの迅速なアクションに使用されるサポートプロンプトを設定します。これらのプロンプトは、Rooが一般的な開発タスクでより良いサポートを提供するのに役立ちます。" + "description": "プロンプトの強化、コードの説明、問題の修正などの迅速なアクションに使用されるサポートプロンプトを設定します。これらのプロンプトは、Zooが一般的な開発タスクでより良いサポートを提供するのに役立ちます。" }, "codeIndex": { "title": "コードベースのインデックス作成", @@ -283,16 +283,16 @@ }, "autoApprove": { "toggleShortcut": "IDEの環境設定で、この設定のグローバルショートカットを設定できます。", - "description": "Rooが承認なしで自動的に操作を実行できるようにします。AIを完全に信頼し、関連するセキュリティリスクを理解している場合にのみ、これらの設定を有効にしてください。", + "description": "Zooが承認なしで自動的に操作を実行できるようにします。AIを完全に信頼し、関連するセキュリティリスクを理解している場合にのみ、これらの設定を有効にしてください。", "enabled": "自動承認が有効", "toggleAriaLabel": "自動承認の切り替え", "disabledAriaLabel": "自動承認が無効です - 最初にオプションを選択してください", "readOnly": { "label": "読み取り", - "description": "有効にすると、Rooは承認ボタンをクリックすることなく、自動的にディレクトリの内容を表示してファイルを読み取ります。", + "description": "有効にすると、Zooは承認ボタンをクリックすることなく、自動的にディレクトリの内容を表示してファイルを読み取ります。", "outsideWorkspace": { "label": "ワークスペース外のファイルを含める", - "description": "Rooが承認なしで現在のワークスペース外のファイルを読み取ることを許可します。" + "description": "Zooが承認なしで現在のワークスペース外のファイルを読み取ることを許可します。" } }, "write": { @@ -301,11 +301,11 @@ "delayLabel": "診断が潜在的な問題を検出できるよう、書き込み後に遅延を設ける", "outsideWorkspace": { "label": "ワークスペース外のファイルを含める", - "description": "Rooが承認なしで現在のワークスペース外のファイルを作成・編集することを許可します。" + "description": "Zooが承認なしで現在のワークスペース外のファイルを作成・編集することを許可します。" }, "protected": { "label": "保護されたファイルを含める", - "description": "Rooが保護されたファイル(.rooignoreや.roo/設定ファイルなど)を承認なしで作成・編集することを許可します。" + "description": "Zooが保護されたファイル(.rooignoreや.roo/設定ファイルなど)を承認なしで作成・編集することを許可します。" } }, "mcp": { @@ -616,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "エラーと繰り返しの制限", - "description": "「Rooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数。この安全機構を無効にするには0に設定します(トリガーされません)。", + "description": "「Zooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数。この安全機構を無効にするには0に設定します(トリガーされません)。", "unlimitedDescription": "無制限のリトライが有効です(自動進行)。ダイアログは表示されません。", "warning": "⚠️ 0に設定すると無制限のリトライが可能になり、API使用量が大幅に増加する可能性があります" }, @@ -660,18 +660,18 @@ }, "enable": { "label": "自動チェックポイントを有効化", - "description": "有効にすると、Rooはタスク実行中に自動的にチェックポイントを作成し、変更の確認や以前の状態への復帰を容易にします。 <0>詳細情報" + "description": "有効にすると、Zooはタスク実行中に自動的にチェックポイントを作成し、変更の確認や以前の状態への復帰を容易にします。 <0>詳細情報" } }, "notifications": { "sound": { "label": "サウンドエフェクトを有効化", - "description": "有効にすると、Rooは通知やイベントのためにサウンドエフェクトを再生します。", + "description": "有効にすると、Zooは通知やイベントのためにサウンドエフェクトを再生します。", "volumeLabel": "音量" }, "tts": { "label": "音声合成を有効化", - "description": "有効にすると、Rooは音声合成を使用して応答を音声で読み上げます。", + "description": "有効にすると、Zooは音声合成を使用して応答を音声で読み上げます。", "speedLabel": "速度" } }, @@ -679,7 +679,7 @@ "description": "AIのコンテキストウィンドウに含まれる情報を制御し、token使用量とレスポンスの品質に影響します", "autoCondenseContextPercent": { "label": "インテリジェントなコンテキスト圧縮をトリガーするしきい値", - "description": "コンテキストウィンドウがこのしきい値に達すると、Rooは自動的に圧縮します。" + "description": "コンテキストウィンドウがこのしきい値に達すると、Zooは自動的に圧縮します。" }, "condensingApiConfiguration": { "label": "コンテキスト圧縮用のAPI設定", @@ -695,7 +695,7 @@ }, "autoCondenseContext": { "name": "インテリジェントなコンテキスト圧縮を自動的にトリガーする", - "description": "有効にすると、Rooは閾値に達したときに自動的にコンテキストを圧縮します。無効にすると、手動でコンテキスト圧縮をトリガーできます。" + "description": "有効にすると、Zooは閾値に達したときに自動的にコンテキストを圧縮します。無効にすると、手動でコンテキスト圧縮をトリガーできます。" }, "openTabs": { "label": "オープンタブコンテキスト制限", @@ -711,7 +711,7 @@ }, "maxReadFile": { "label": "ファイル読み込み自動切り詰めしきい値", - "description": "モデルが開始/終了の値を指定しない場合、Rooはこの行数を読み込みます。この数がファイルの総行数より少ない場合、Rooはコード定義の行番号インデックスを生成します。特殊なケース:-1はRooにファイル全体を読み込むよう指示し(インデックス作成なし)、0は行を読み込まず最小限のコンテキストのために行インデックスのみを提供するよう指示します。低い値は初期コンテキスト使用量を最小限に抑え、後続の正確な行範囲の読み込みを可能にします。明示的な開始/終了の要求はこの設定による制限を受けません。", + "description": "モデルが開始/終了の値を指定しない場合、Zooはこの行数を読み込みます。この数がファイルの総行数より少ない場合、Zooはコード定義の行番号インデックスを生成します。特殊なケース:-1はZooにファイル全体を読み込むよう指示し(インデックス作成なし)、0は行を読み込まず最小限のコンテキストのために行インデックスのみを提供するよう指示します。低い値は初期コンテキスト使用量を最小限に抑え、後続の正確な行範囲の読み込みを可能にします。明示的な開始/終了の要求はこの設定による制限を受けません。", "lines": "行", "always_full_read": "常にファイル全体を読み込む" }, @@ -788,15 +788,15 @@ }, "outputLineLimit": { "label": "ターミナル出力制限", - "description": "制限内に収めるため最初と最後の行を保持し、中間を削除します。トークンを節約するには下げる;Rooに中間の詳細を与えるには上げる。Rooはコンテンツがスキップされた箇所にプレースホルダーを表示します。<0>詳細情報" + "description": "制限内に収めるため最初と最後の行を保持し、中間を削除します。トークンを節約するには下げる;Zooに中間の詳細を与えるには上げる。Zooはコンテンツがスキップされた箇所にプレースホルダーを表示します。<0>詳細情報" }, "outputCharacterLimit": { "label": "ターミナル文字制限", - "description": "出力サイズにハードキャップを適用してメモリ問題を防ぐため、行制限を上書きします。超過した場合、最初と最後を保持し、コンテンツがスキップされた箇所にRooにプレースホルダーを表示します。<0>詳細情報" + "description": "出力サイズにハードキャップを適用してメモリ問題を防ぐため、行制限を上書きします。超過した場合、最初と最後を保持し、コンテンツがスキップされた箇所にZooにプレースホルダーを表示します。<0>詳細情報" }, "outputPreviewSize": { "label": "コマンド出力プレビューサイズ", - "description": "Rooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。", + "description": "Zooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。", "options": { "small": "小 (5KB)", "medium": "中 (10KB)", @@ -853,7 +853,7 @@ "advanced": { "diff": { "label": "diff経由の編集を有効化", - "description": "有効にすると、Rooはファイルをより迅速に編集でき、切り詰められた全ファイル書き込みを自動的に拒否します。", + "description": "有効にすると、Zooはファイルをより迅速に編集でき、切り詰められた全ファイル書き込みを自動的に拒否します。", "strategy": { "label": "Diff戦略", "options": { @@ -870,7 +870,7 @@ }, "todoList": { "label": "ToDoリストツールを有効にする", - "description": "有効にすると、Rooはタスクの進捗を追跡するためのToDoリストを作成・管理できます。これにより、複雑なタスクを管理しやすいステップに整理できます。" + "description": "有効にすると、Zooはタスクの進捗を追跡するためのToDoリストを作成・管理できます。これにより、複雑なタスクを管理しやすいステップに整理できます。" } }, "experimental": { @@ -880,15 +880,15 @@ }, "INSERT_BLOCK": { "name": "実験的なコンテンツ挿入ツールを使用する", - "description": "実験的なコンテンツ挿入ツールを有効にし、Rooがdiffを作成せずに特定の行番号にコンテンツを挿入できるようにします。" + "description": "実験的なコンテンツ挿入ツールを有効にし、Zooがdiffを作成せずに特定の行番号にコンテンツを挿入できるようにします。" }, "MULTI_SEARCH_AND_REPLACE": { "name": "実験的なマルチブロックdiffツールを使用する", - "description": "有効にすると、Rooはマルチブロックdiffツールを使用します。これにより、1つのリクエストでファイル内の複数のコードブロックを更新しようとします。" + "description": "有効にすると、Zooはマルチブロックdiffツールを使用します。これにより、1つのリクエストでファイル内の複数のコードブロックを更新しようとします。" }, "CONCURRENT_FILE_READS": { "name": "並行ファイル読み取りを有効にする", - "description": "有効にすると、Rooは1回のリクエストで複数のファイル を読み取ることができます。無効にすると、Rooはファイルを1つずつ読み取る必要があります。能力の低いモデルで作業する場合や、ファイルアクセスをより細かく制御したい場合は、無効にすると役立ちます。" + "description": "有効にすると、Zooは1回のリクエストで複数のファイル を読み取ることができます。無効にすると、Zooはファイルを1つずつ読み取る必要があります。能力の低いモデルで作業する場合や、ファイルアクセスをより細かく制御したい場合は、無効にすると役立ちます。" }, "MARKETPLACE": { "name": "Marketplaceを有効にする", @@ -896,7 +896,7 @@ }, "PREVENT_FOCUS_DISRUPTION": { "name": "バックグラウンド編集", - "description": "有効にすると、エディターのフォーカス中断を防ぎます。ファイル編集は差分ビューを開いたりフォーカスを奪ったりすることなく、バックグラウンドで行われます。Rooが変更を行っている間も中断されることなく作業を続けることができます。ファイルは診断をキャプチャするためにフォーカスなしで開くか、完全に閉じたままにできます。" + "description": "有効にすると、エディターのフォーカス中断を防ぎます。ファイル編集は差分ビューを開いたりフォーカスを奪ったりすることなく、バックグラウンドで行われます。Zooが変更を行っている間も中断されることなく作業を続けることができます。ファイルは診断をキャプチャするためにフォーカスなしで開くか、完全に閉じたままにできます。" }, "ASSISTANT_MESSAGE_PARSER": { "name": "新しいメッセージパーサーを使う", @@ -910,7 +910,7 @@ "providerLabel": "プロバイダー", "providerDescription": "画像生成に使用するプロバイダーを選択", "name": "AI画像生成を有効にする", - "description": "有効にすると、RooはOpenRouterの画像生成モデルを使用してテキストプロンプトから画像を生成できます。OpenRouter APIキーの設定が必要です。", + "description": "有効にすると、ZooはOpenRouterの画像生成モデルを使用してテキストプロンプトから画像を生成できます。OpenRouter APIキーの設定が必要です。", "openRouterApiKeyLabel": "OpenRouter APIキー", "openRouterApiKeyPlaceholder": "OpenRouter APIキーを入力してください", "getApiKeyText": "APIキーを取得する場所", @@ -921,11 +921,11 @@ }, "RUN_SLASH_COMMAND": { "name": "モデル開始スラッシュコマンドを有効にする", - "description": "有効にすると、Rooがワークフローを実行するためにあなたのスラッシュコマンドを実行できます。" + "description": "有効にすると、Zooがワークフローを実行するためにあなたのスラッシュコマンドを実行できます。" }, "CUSTOM_TOOLS": { "name": "カスタムツールを有効化", - "description": "有効にすると、Rooはプロジェクトの.roo/toolsディレクトリまたはグローバルツール用の~/.roo/toolsからカスタムTypeScript/JavaScriptツールを読み込んで使用できます。注意:これらのツールは自動的に承認されます。", + "description": "有効にすると、Zooはプロジェクトの.roo/toolsディレクトリまたはグローバルツール用の~/.roo/toolsからカスタムTypeScript/JavaScriptツールを読み込んで使用できます。注意:これらのツールは自動的に承認されます。", "toolsHeader": "利用可能なカスタムツール", "noTools": "カスタムツールが読み込まれていません。プロジェクトの.roo/toolsディレクトリまたはグローバルツール用の~/.roo/toolsに.tsまたは.jsファイルを追加してください。", "refreshButton": "更新", @@ -937,7 +937,7 @@ }, "promptCaching": { "label": "プロンプトキャッシュを無効化", - "description": "チェックすると、Rooはこのモデルに対してプロンプトキャッシュを使用しません。" + "description": "チェックすると、Zooはこのモデルに対してプロンプトキャッシュを使用しません。" }, "temperature": { "useCustom": "カスタム温度を使用", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 53186e7afe..ac7b6c4c05 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -130,7 +130,7 @@ "title": "모드", "marketplace": "모드 마켓플레이스", "settings": "모드 설정", - "description": "Roo의 행동을 맞춤화하는 전문화된 페르소나.", + "description": "Zoo의 행동을 맞춤화하는 전문화된 페르소나.", "searchPlaceholder": "모드 검색...", "noResults": "결과를 찾을 수 없습니다" }, @@ -144,7 +144,7 @@ "diffError": { "title": "편집 실패" }, - "troubleMessage": "Roo에 문제가 발생했습니다...", + "troubleMessage": "Zoo에 문제가 발생했습니다...", "apiRequest": { "title": "API 요청", "failed": "API 요청 실패", @@ -187,43 +187,43 @@ "current": "현재" }, "instructions": { - "wantsToFetch": "Roo는 현재 작업을 지원하기 위해 자세한 지침을 가져오려고 합니다" + "wantsToFetch": "Zoo는 현재 작업을 지원하기 위해 자세한 지침을 가져오려고 합니다" }, "fileOperations": { - "wantsToRead": "Roo가 이 파일을 읽고 싶어합니다", - "wantsToReadOutsideWorkspace": "Roo가 워크스페이스 외부의 이 파일을 읽고 싶어합니다", - "didRead": "Roo가 이 파일을 읽었습니다", - "wantsToEdit": "Roo가 이 파일을 편집하고 싶어합니다", - "wantsToEditOutsideWorkspace": "Roo가 워크스페이스 외부의 이 파일을 편집하고 싶어합니다", - "wantsToEditProtected": "Roo가 보호된 설정 파일을 편집하고 싶어합니다", - "wantsToCreate": "Roo가 새 파일을 만들고 싶어합니다", - "wantsToSearchReplace": "Roo가 이 파일에서 검색 및 바꾸기를 수행하고 싶어합니다", - "didSearchReplace": "Roo가 이 파일에서 검색 및 바꾸기를 수행했습니다", - "wantsToInsert": "Roo가 이 파일에 내용을 삽입하고 싶어합니다", - "wantsToInsertWithLineNumber": "Roo가 이 파일의 {{lineNumber}}번 줄에 내용을 삽입하고 싶어합니다", - "wantsToInsertAtEnd": "Roo가 이 파일의 끝에 내용을 추가하고 싶어합니다", - "wantsToReadAndXMore": "Roo가 이 파일과 {{count}}개의 파일을 더 읽으려고 합니다", - "wantsToReadMultiple": "Roo가 여러 파일을 읽으려고 합니다", - "wantsToApplyBatchChanges": "Roo가 여러 파일에 변경 사항을 적용하고 싶어합니다", - "wantsToGenerateImage": "Roo가 이미지를 생성하고 싶어합니다", - "wantsToGenerateImageOutsideWorkspace": "Roo가 워크스페이스 외부에서 이미지를 생성하고 싶어합니다", - "wantsToGenerateImageProtected": "Roo가 보호된 위치에서 이미지를 생성하고 싶어합니다", - "didGenerateImage": "Roo가 이미지를 생성했습니다" + "wantsToRead": "Zoo가 이 파일을 읽고 싶어합니다", + "wantsToReadOutsideWorkspace": "Zoo가 워크스페이스 외부의 이 파일을 읽고 싶어합니다", + "didRead": "Zoo가 이 파일을 읽었습니다", + "wantsToEdit": "Zoo가 이 파일을 편집하고 싶어합니다", + "wantsToEditOutsideWorkspace": "Zoo가 워크스페이스 외부의 이 파일을 편집하고 싶어합니다", + "wantsToEditProtected": "Zoo가 보호된 설정 파일을 편집하고 싶어합니다", + "wantsToCreate": "Zoo가 새 파일을 만들고 싶어합니다", + "wantsToSearchReplace": "Zoo가 이 파일에서 검색 및 바꾸기를 수행하고 싶어합니다", + "didSearchReplace": "Zoo가 이 파일에서 검색 및 바꾸기를 수행했습니다", + "wantsToInsert": "Zoo가 이 파일에 내용을 삽입하고 싶어합니다", + "wantsToInsertWithLineNumber": "Zoo가 이 파일의 {{lineNumber}}번 줄에 내용을 삽입하고 싶어합니다", + "wantsToInsertAtEnd": "Zoo가 이 파일의 끝에 내용을 추가하고 싶어합니다", + "wantsToReadAndXMore": "Zoo가 이 파일과 {{count}}개의 파일을 더 읽으려고 합니다", + "wantsToReadMultiple": "Zoo가 여러 파일을 읽으려고 합니다", + "wantsToApplyBatchChanges": "Zoo가 여러 파일에 변경 사항을 적용하고 싶어합니다", + "wantsToGenerateImage": "Zoo가 이미지를 생성하고 싶어합니다", + "wantsToGenerateImageOutsideWorkspace": "Zoo가 워크스페이스 외부에서 이미지를 생성하고 싶어합니다", + "wantsToGenerateImageProtected": "Zoo가 보호된 위치에서 이미지를 생성하고 싶어합니다", + "didGenerateImage": "Zoo가 이미지를 생성했습니다" }, "directoryOperations": { - "wantsToViewTopLevel": "Roo가 이 디렉토리의 최상위 파일을 보고 싶어합니다", - "didViewTopLevel": "Roo가 이 디렉토리의 최상위 파일을 보았습니다", - "wantsToViewRecursive": "Roo가 이 디렉토리의 모든 파일을 재귀적으로 보고 싶어합니다", - "didViewRecursive": "Roo가 이 디렉토리의 모든 파일을 재귀적으로 보았습니다", - "wantsToSearch": "Roo가 이 디렉토리에서 {{regex}}을(를) 검색하고 싶어합니다", - "didSearch": "Roo가 이 디렉토리에서 {{regex}}을(를) 검색했습니다", - "wantsToSearchOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 {{regex}}을(를) 검색하고 싶어합니다", - "didSearchOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 {{regex}}을(를) 검색했습니다", - "wantsToViewTopLevelOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보고 싶어합니다", - "didViewTopLevelOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보았습니다", - "wantsToViewRecursiveOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보고 싶어합니다", - "didViewRecursiveOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보았습니다", - "wantsToViewMultipleDirectories": "Roo가 여러 디렉토리를 보려고 합니다" + "wantsToViewTopLevel": "Zoo가 이 디렉토리의 최상위 파일을 보고 싶어합니다", + "didViewTopLevel": "Zoo가 이 디렉토리의 최상위 파일을 보았습니다", + "wantsToViewRecursive": "Zoo가 이 디렉토리의 모든 파일을 재귀적으로 보고 싶어합니다", + "didViewRecursive": "Zoo가 이 디렉토리의 모든 파일을 재귀적으로 보았습니다", + "wantsToSearch": "Zoo가 이 디렉토리에서 {{regex}}을(를) 검색하고 싶어합니다", + "didSearch": "Zoo가 이 디렉토리에서 {{regex}}을(를) 검색했습니다", + "wantsToSearchOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)에서 {{regex}}을(를) 검색하고 싶어합니다", + "didSearchOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)에서 {{regex}}을(를) 검색했습니다", + "wantsToViewTopLevelOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보고 싶어합니다", + "didViewTopLevelOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보았습니다", + "wantsToViewRecursiveOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보고 싶어합니다", + "didViewRecursiveOutsideWorkspace": "Zoo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보았습니다", + "wantsToViewMultipleDirectories": "Zoo가 여러 디렉토리를 보려고 합니다" }, "commandOutput": "명령 출력", "commandExecution": { @@ -252,18 +252,18 @@ "youSaid": "당신은 말했다" }, "mcp": { - "wantsToUseTool": "Roo가 {{serverName}} MCP 서버에서 도구를 사용하고 싶어합니다", - "wantsToAccessResource": "Roo가 {{serverName}} MCP 서버에서 리소스에 접근하고 싶어합니다" + "wantsToUseTool": "Zoo가 {{serverName}} MCP 서버에서 도구를 사용하고 싶어합니다", + "wantsToAccessResource": "Zoo가 {{serverName}} MCP 서버에서 리소스에 접근하고 싶어합니다" }, "modes": { - "wantsToSwitch": "Roo가 {{mode}} 모드로 전환하고 싶어합니다", - "wantsToSwitchWithReason": "Roo가 다음 이유로 {{mode}} 모드로 전환하고 싶어합니다: {{reason}}", - "didSwitch": "Roo가 {{mode}} 모드로 전환했습니다", - "didSwitchWithReason": "Roo가 다음 이유로 {{mode}} 모드로 전환했습니다: {{reason}}" + "wantsToSwitch": "Zoo가 {{mode}} 모드로 전환하고 싶어합니다", + "wantsToSwitchWithReason": "Zoo가 다음 이유로 {{mode}} 모드로 전환하고 싶어합니다: {{reason}}", + "didSwitch": "Zoo가 {{mode}} 모드로 전환했습니다", + "didSwitchWithReason": "Zoo가 다음 이유로 {{mode}} 모드로 전환했습니다: {{reason}}" }, "subtasks": { - "wantsToCreate": "Roo가 {{mode}} 모드에서 새 하위 작업을 만들고 싶어합니다", - "wantsToFinish": "Roo가 이 하위 작업을 완료하고 싶어합니다", + "wantsToCreate": "Zoo가 {{mode}} 모드에서 새 하위 작업을 만들고 싶어합니다", + "wantsToFinish": "Zoo가 이 하위 작업을 완료하고 싶어합니다", "newTaskContent": "하위 작업 지침", "completionContent": "하위 작업 완료", "resultContent": "하위 작업 결과", @@ -272,7 +272,7 @@ "goToSubtask": "작업 보기" }, "questions": { - "hasQuestion": "Roo에게 질문이 있습니다" + "hasQuestion": "Zoo에게 질문이 있습니다" }, "taskCompleted": "작업 완료", "modelResponseIncomplete": "모델 응답 불완전", @@ -288,7 +288,7 @@ "copyToClipboard": "클립보드에 복사", "copied": "복사됨!", "diagnostics": "상세한 오류 정보 가져오기", - "proxyProvider": "프록시 기반 제공자를 사용하는 것 같습니다. 로그를 확인하고 Roo의 요청을 다시 작성하지 않는지 확인하세요." + "proxyProvider": "프록시 기반 제공자를 사용하는 것 같습니다. 로그를 확인하고 Zoo의 요청을 다시 작성하지 않는지 확인하세요." }, "powershell": { "issues": "Windows PowerShell에 문제가 있는 것 같습니다. 다음을 참조하세요" @@ -331,8 +331,8 @@ } }, "skill": { - "wantsToLoad": "Roo가 스킬을 로드하려고 합니다", - "didLoad": "Roo가 스킬을 로드했습니다" + "wantsToLoad": "Zoo가 스킬을 로드하려고 합니다", + "didLoad": "Zoo가 스킬을 로드했습니다" }, "followUpSuggest": { "copyToInput": "입력창에 복사 (또는 Shift + 클릭)", @@ -375,18 +375,18 @@ "ask": { "autoApprovedRequestLimitReached": { "title": "자동 승인 요청 한도 도달", - "description": "Roo가 {{count}}개의 API 요청(들)에 대한 자동 승인 한도에 도달했습니다. 카운트를 재설정하고 작업을 계속하시겠습니까?", + "description": "Zoo가 {{count}}개의 API 요청(들)에 대한 자동 승인 한도에 도달했습니다. 카운트를 재설정하고 작업을 계속하시겠습니까?", "button": "재설정 후 계속" }, "autoApprovedCostLimitReached": { - "description": "Roo가 자동 승인된 비용 한도인 ${{count}}에 도달했습니다. 비용을 초기화하고 작업을 계속하시겠습니까?", + "description": "Zoo가 자동 승인된 비용 한도인 ${{count}}에 도달했습니다. 비용을 초기화하고 작업을 계속하시겠습니까?", "title": "자동 승인 비용 한도에 도달함", "button": "재설정 후 계속하기" } }, "codebaseSearch": { - "wantsToSearch": "Roo가 코드베이스에서 {{query}}을(를) 검색하고 싶어합니다", - "wantsToSearchWithPath": "Roo가 {{path}}에서 {{query}}을(를) 검색하고 싶어합니다", + "wantsToSearch": "Zoo가 코드베이스에서 {{query}}을(를) 검색하고 싶어합니다", + "wantsToSearchWithPath": "Zoo가 {{path}}에서 {{query}}을(를) 검색하고 싶어합니다", "didSearch_one": "1개의 결과를 찾았습니다", "didSearch_other": "{{count}}개의 결과를 찾았습니다", "resultTooltip": "유사도 점수: {{score}} (클릭하여 파일 열기)" @@ -468,8 +468,8 @@ "clickToEdit": "클릭하여 메시지 편집" }, "slashCommand": { - "wantsToRun": "Roo가 슬래시 명령어를 실행하려고 합니다", - "didRun": "Roo가 슬래시 명령어를 실행했습니다" + "wantsToRun": "Zoo가 슬래시 명령어를 실행하려고 합니다", + "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index 204547e4c3..90ac4d0905 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -9,7 +9,7 @@ "editModesConfig": "모드 구성 편집", "editGlobalModes": "전역 모드 편집", "editProjectModes": "프로젝트 모드 편집 (.roomodes)", - "createModeHelpText": "모드는 Roo의 행동을 맞춤화하는 특화된 페르소나입니다. <0>모드 사용에 대해 알아보기 또는 <1>모드 사용자 정의하기.", + "createModeHelpText": "모드는 Zoo의 행동을 맞춤화하는 특화된 페르소나입니다. <0>모드 사용에 대해 알아보기 또는 <1>모드 사용자 정의하기.", "selectMode": "모드 검색" }, "apiConfiguration": { @@ -33,7 +33,7 @@ "roleDefinition": { "title": "역할 정의", "resetToDefault": "기본값으로 재설정", - "description": "이 모드에 대한 Roo의 전문성과 성격을 정의하세요. 이 설명은 Roo가 자신을 어떻게 표현하고 작업에 접근하는지 형성합니다." + "description": "이 모드에 대한 Zoo의 전문성과 성격을 정의하세요. 이 설명은 Zoo가 자신을 어떻게 표현하고 작업에 접근하는지 형성합니다." }, "description": { "title": "짧은 설명 (사람용)", @@ -102,7 +102,7 @@ "types": { "ENHANCE": { "label": "프롬프트 향상", - "description": "입력에 맞춤화된 제안이나 개선을 얻기 위해 프롬프트 향상을 사용하세요. 이를 통해 Roo가 의도를 이해하고 최상의 응답을 제공할 수 있습니다. 채팅에서 ✨ 아이콘을 통해 이용 가능합니다." + "description": "입력에 맞춤화된 제안이나 개선을 얻기 위해 프롬프트 향상을 사용하세요. 이를 통해 Zoo가 의도를 이해하고 최상의 응답을 제공할 수 있습니다. 채팅에서 ✨ 아이콘을 통해 이용 가능합니다." }, "CONDENSE": { "label": "컨텍스트 압축", @@ -167,7 +167,7 @@ }, "roleDefinition": { "label": "역할 정의", - "description": "이 모드에 대한 Roo의 전문성과 성격을 정의하세요." + "description": "이 모드에 대한 Zoo의 전문성과 성격을 정의하세요." }, "whenToUse": { "label": "사용 시기 (선택 사항)", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index ae8fd3b9ef..40313a32f5 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -165,7 +165,7 @@ "footer": "규칙은 일반 지침보다 모드별 지침을 먼저 로드합니다. 전역 규칙은 워크스페이스 규칙보다 먼저 로드됩니다." }, "prompts": { - "description": "프롬프트 향상, 코드 설명, 문제 해결과 같은 빠른 작업에 사용되는 지원 프롬프트를 구성합니다. 이러한 프롬프트는 Roo가 일반적인 개발 작업에 대해 더 나은 지원을 제공하는 데 도움이 됩니다." + "description": "프롬프트 향상, 코드 설명, 문제 해결과 같은 빠른 작업에 사용되는 지원 프롬프트를 구성합니다. 이러한 프롬프트는 Zoo가 일반적인 개발 작업에 대해 더 나은 지원을 제공하는 데 도움이 됩니다." }, "codeIndex": { "title": "코드베이스 인덱싱", @@ -283,16 +283,16 @@ }, "autoApprove": { "toggleShortcut": "IDE 환경 설정에서 이 설정에 대한 전역 바로 가기를 구성할 수 있습니다.", - "description": "Roo가 승인 없이 자동으로 작업을 수행할 수 있도록 허용합니다. AI를 완전히 신뢰하고 관련 보안 위험을 이해하는 경우에만 이러한 설정을 활성화하세요.", + "description": "Zoo가 승인 없이 자동으로 작업을 수행할 수 있도록 허용합니다. AI를 완전히 신뢰하고 관련 보안 위험을 이해하는 경우에만 이러한 설정을 활성화하세요.", "enabled": "자동 승인 활성화됨", "toggleAriaLabel": "자동 승인 전환", "disabledAriaLabel": "자동 승인 비활성화됨 - 먼저 옵션을 선택하세요", "readOnly": { "label": "읽기", - "description": "활성화되면 Roo는 승인 버튼을 클릭하지 않고도 자동으로 디렉토리 내용을 보고 파일을 읽습니다.", + "description": "활성화되면 Zoo는 승인 버튼을 클릭하지 않고도 자동으로 디렉토리 내용을 보고 파일을 읽습니다.", "outsideWorkspace": { "label": "워크스페이스 외부 파일 포함", - "description": "Roo가 승인 없이 현재 워크스페이스 외부의 파일을 읽을 수 있도록 허용합니다." + "description": "Zoo가 승인 없이 현재 워크스페이스 외부의 파일을 읽을 수 있도록 허용합니다." } }, "write": { @@ -301,11 +301,11 @@ "delayLabel": "진단이 잠재적 문제를 감지할 수 있도록 쓰기 후 지연", "outsideWorkspace": { "label": "워크스페이스 외부 파일 포함", - "description": "Roo가 승인 없이 현재 워크스페이스 외부의 파일을 생성하고 편집할 수 있도록 허용합니다." + "description": "Zoo가 승인 없이 현재 워크스페이스 외부의 파일을 생성하고 편집할 수 있도록 허용합니다." }, "protected": { "label": "보호된 파일 포함", - "description": "Roo가 보호된 파일(.rooignore 및 .roo/ 구성 파일 등)을 승인 없이 생성하고 편집할 수 있도록 허용합니다." + "description": "Zoo가 보호된 파일(.rooignore 및 .roo/ 구성 파일 등)을 승인 없이 생성하고 편집할 수 있도록 허용합니다." } }, "mcp": { @@ -616,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "오류 및 반복 제한", - "description": "'Roo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수. 이 안전 메커니즘을 비활성화하려면 0으로 설정하세요(절대 트리거되지 않음).", + "description": "'Zoo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수. 이 안전 메커니즘을 비활성화하려면 0으로 설정하세요(절대 트리거되지 않음).", "unlimitedDescription": "무제한 재시도 활성화 (자동 진행). 대화 상자가 나타나지 않습니다.", "warning": "⚠️ 0으로 설정하면 무제한 재시도가 허용되어 상당한 API 사용량이 발생할 수 있습니다" }, @@ -660,18 +660,18 @@ }, "enable": { "label": "자동 체크포인트 활성화", - "description": "활성화되면 Roo는 작업 실행 중에 자동으로 체크포인트를 생성하여 변경 사항을 검토하거나 이전 상태로 되돌리기 쉽게 합니다. <0>더 알아보기" + "description": "활성화되면 Zoo는 작업 실행 중에 자동으로 체크포인트를 생성하여 변경 사항을 검토하거나 이전 상태로 되돌리기 쉽게 합니다. <0>더 알아보기" } }, "notifications": { "sound": { "label": "사운드 효과 활성화", - "description": "활성화되면 Roo는 알림 및 이벤트에 대한 사운드 효과를 재생합니다.", + "description": "활성화되면 Zoo는 알림 및 이벤트에 대한 사운드 효과를 재생합니다.", "volumeLabel": "볼륨" }, "tts": { "label": "음성 합성 활성화", - "description": "활성화되면 Roo는 음성 합성을 사용하여 응답을 소리내어 읽습니다.", + "description": "활성화되면 Zoo는 음성 합성을 사용하여 응답을 소리내어 읽습니다.", "speedLabel": "속도" } }, @@ -679,7 +679,7 @@ "description": "AI의 컨텍스트 창에 포함되는 정보를 제어하여 token 사용량과 응답 품질에 영향을 미칩니다", "autoCondenseContextPercent": { "label": "지능적 컨텍스트 압축을 트리거하는 임계값", - "description": "컨텍스트 창이 이 임계값에 도달하면 Roo가 자동으로 압축합니다." + "description": "컨텍스트 창이 이 임계값에 도달하면 Zoo가 자동으로 압축합니다." }, "condensingApiConfiguration": { "label": "컨텍스트 압축을 위한 API 설정", @@ -695,7 +695,7 @@ }, "autoCondenseContext": { "name": "지능적 컨텍스트 압축 자동 트리거", - "description": "활성화되면 Roo는 임계값에 도달했을 때 자동으로 컨텍스트를 압축합니다. 비활성화되면 여전히 수동으로 컨텍스트 압축을 트리거할 수 있습니다." + "description": "활성화되면 Zoo는 임계값에 도달했을 때 자동으로 컨텍스트를 압축합니다. 비활성화되면 여전히 수동으로 컨텍스트 압축을 트리거할 수 있습니다." }, "openTabs": { "label": "열린 탭 컨텍스트 제한", @@ -711,7 +711,7 @@ }, "maxReadFile": { "label": "파일 읽기 자동 축소 임계값", - "description": "모델이 시작/끝 값을 지정하지 않을 때 Roo가 읽는 줄 수입니다. 이 수가 파일의 총 줄 수보다 적으면 Roo는 코드 정의의 줄 번호 인덱스를 생성합니다. 특수한 경우: -1은 Roo에게 전체 파일을 읽도록 지시하고(인덱싱 없이), 0은 줄을 읽지 않고 최소한의 컨텍스트를 위해 줄 인덱스만 제공하도록 지시합니다. 낮은 값은 초기 컨텍스트 사용을 최소화하고, 이후 정확한 줄 범위 읽기를 가능하게 합니다. 명시적 시작/끝 요청은 이 설정의 제한을 받지 않습니다.", + "description": "모델이 시작/끝 값을 지정하지 않을 때 Zoo가 읽는 줄 수입니다. 이 수가 파일의 총 줄 수보다 적으면 Zoo는 코드 정의의 줄 번호 인덱스를 생성합니다. 특수한 경우: -1은 Zoo에게 전체 파일을 읽도록 지시하고(인덱싱 없이), 0은 줄을 읽지 않고 최소한의 컨텍스트를 위해 줄 인덱스만 제공하도록 지시합니다. 낮은 값은 초기 컨텍스트 사용을 최소화하고, 이후 정확한 줄 범위 읽기를 가능하게 합니다. 명시적 시작/끝 요청은 이 설정의 제한을 받지 않습니다.", "lines": "줄", "always_full_read": "항상 전체 파일 읽기" }, @@ -788,15 +788,15 @@ }, "outputLineLimit": { "label": "터미널 출력 제한", - "description": "제한 내로 유지하기 위해 첫 줄과 마지막 줄을 유지하고 중간을 삭제합니다. 토큰을 절약하려면 낮추고; Roo에게 더 많은 중간 세부 정보를 제공하려면 높입니다. Roo는 콘텐츠가 건너뛴 곳에 자리 표시자를 봅니다.<0>자세히 알아보기" + "description": "제한 내로 유지하기 위해 첫 줄과 마지막 줄을 유지하고 중간을 삭제합니다. 토큰을 절약하려면 낮추고; Zoo에게 더 많은 중간 세부 정보를 제공하려면 높입니다. Zoo는 콘텐츠가 건너뛴 곳에 자리 표시자를 봅니다.<0>자세히 알아보기" }, "outputCharacterLimit": { "label": "터미널 문자 제한", - "description": "출력 크기에 대한 엄격한 상한을 적용하여 메모리 문제를 방지하기 위해 줄 제한을 재정의합니다. 초과하면 시작과 끝을 유지하고 내용이 생략된 곳에 Roo에게 자리 표시자를 표시합니다. <0>자세히 알아보기" + "description": "출력 크기에 대한 엄격한 상한을 적용하여 메모리 문제를 방지하기 위해 줄 제한을 재정의합니다. 초과하면 시작과 끝을 유지하고 내용이 생략된 곳에 Zoo에게 자리 표시자를 표시합니다. <0>자세히 알아보기" }, "outputPreviewSize": { "label": "명령 출력 미리보기 크기", - "description": "Roo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.", + "description": "Zoo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.", "options": { "small": "작게 (5KB)", "medium": "보통 (10KB)", @@ -809,7 +809,7 @@ }, "shellIntegrationDisabled": { "label": "인라인 터미널 사용(권장)", - "description": "더 빠르고 안정적인 실행을 위해 셸 프로필/통합��� 우회하려면 인라인 터미널(채팅)에�� 명령을 실행하십시오. 비활성화하면 Roo는 셸 프로필, 프롬프트 및 플러그인과 함께 VS Code 터미널을 사용합니다. <0>자세히 알아보기" + "description": "더 빠르고 안정적인 실행을 위해 셸 프로필/통합을 우회하려면 인라인 터미널(채팅)에서 명령을 실행하십시오. 비활성화하면 Zoo는 셸 프로필, 프롬프트 및 플러그인과 함께 VS Code 터미널을 사용합니다. <0>자세히 알아보기" }, "commandDelay": { "label": "터미널 명령 지연", @@ -837,7 +837,7 @@ }, "inheritEnv": { "label": "환경 변수 상속", - "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" + "description": "부모 VS Code 프로세스에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" }, "profile": { "label": "Zoo Code 터미널 프로필", @@ -853,7 +853,7 @@ "advanced": { "diff": { "label": "diff를 통한 편집 활성화", - "description": "활성화되면 Roo는 파일을 더 빠르게 편집할 수 있으며 잘린 전체 파일 쓰기를 자동으로 거부합니다", + "description": "활성화되면 Zoo는 파일을 더 빠르게 편집할 수 있으며 잘린 전체 파일 쓰기를 자동으로 거부합니다", "strategy": { "label": "Diff 전략", "options": { @@ -870,7 +870,7 @@ }, "todoList": { "label": "할 일 목록 도구 활성화", - "description": "활성화하면 Roo가 작업 진행 상황을 추적하기 위한 할 일 목록을 만들고 관리할 수 있습니다. 이는 복잡한 작업을 관리 가능한 단계로 구성하는 데 도움이 됩니다." + "description": "활성화하면 Zoo가 작업 진행 상황을 추적하기 위한 할 일 목록을 만들고 관리할 수 있습니다. 이는 복잡한 작업을 관리 가능한 단계로 구성하는 데 도움이 됩니다." } }, "experimental": { @@ -880,15 +880,15 @@ }, "INSERT_BLOCK": { "name": "실험적 콘텐츠 삽입 도구 사용", - "description": "실험적 콘텐츠 삽입 도구를 활성화하여 Roo가 diff를 만들 필요 없이 특정 줄 번호에 콘텐츠를 삽입할 수 있게 합니다." + "description": "실험적 콘텐츠 삽입 도구를 활성화하여 Zoo가 diff를 만들 필요 없이 특정 줄 번호에 콘텐츠를 삽입할 수 있게 합니다." }, "MULTI_SEARCH_AND_REPLACE": { "name": "실험적 다중 블록 diff 도구 사용", - "description": "활성화하면 Roo가 다중 블록 diff 도구를 사용합니다. 이것은 하나의 요청에서 파일의 여러 코드 블록을 업데이트하려고 시도합니다." + "description": "활성화하면 Zoo가 다중 블록 diff 도구를 사용합니다. 이것은 하나의 요청에서 파일의 여러 코드 블록을 업데이트하려고 시도합니다." }, "CONCURRENT_FILE_READS": { "name": "동시 파일 읽기 활성화", - "description": "활성화하면 Roo가 한 번의 요청으로 여러 파일 을 읽을 수 있습니다. 비활성화하면 Roo는 파일을 하나씩 읽어야 합니다. 성능이 낮은 모델로 작업하거나 파일 액세스를 더 제어하려는 경우 비활성화하면 도움이 될 수 있습니다." + "description": "활성화하면 Zoo가 한 번의 요청으로 여러 파일을 읽을 수 있습니다. 비활성화하면 Zoo는 파일을 하나씩 읽어야 합니다. 성능이 낮은 모델로 작업하거나 파일 액세스를 더 제어하려는 경우 비활성화하면 도움이 될 수 있습니다." }, "MARKETPLACE": { "name": "Marketplace 활성화", @@ -896,7 +896,7 @@ }, "PREVENT_FOCUS_DISRUPTION": { "name": "백그라운드 편집", - "description": "활성화하면 편집기 포커스 방해를 방지합니다. 파일 편집이 diff 뷰를 열거나 포커스를 빼앗지 않고 백그라운드에서 수행됩니다. Roo가 변경사항을 적용하는 동안 방해받지 않고 계속 작업할 수 있습니다. 파일은 진단을 캡처하기 위해 포커스 없이 열거나 완전히 닫힌 상태로 유지할 수 있습니다." + "description": "활성화하면 편집기 포커스 방해를 방지합니다. 파일 편집이 diff 뷰를 열거나 포커스를 빼앗지 않고 백그라운드에서 수행됩니다. Zoo가 변경사항을 적용하는 동안 방해받지 않고 계속 작업할 수 있습니다. 파일은 진단을 캡처하기 위해 포커스 없이 열거나 완전히 닫힌 상태로 유지할 수 있습니다." }, "ASSISTANT_MESSAGE_PARSER": { "name": "새 메시지 파서 사용", @@ -910,7 +910,7 @@ "providerLabel": "제공업체", "providerDescription": "이미지 생성에 사용할 제공업체를 선택하세요.", "name": "AI 이미지 생성 활성화", - "description": "활성화하면 Roo는 OpenRouter의 이미지 생성 모델을 사용하여 텍스트 프롬프트에서 이미지를 생성할 수 있습니다. OpenRouter API 키 구성이 필요합니다.", + "description": "활성화하면 Zoo는 OpenRouter의 이미지 생성 모델을 사용하여 텍스트 프롬프트에서 이미지를 생성할 수 있습니다. OpenRouter API 키 구성이 필요합니다.", "openRouterApiKeyLabel": "OpenRouter API 키", "openRouterApiKeyPlaceholder": "OpenRouter API 키를 입력하세요", "getApiKeyText": "API 키를 받을 곳", @@ -921,11 +921,11 @@ }, "RUN_SLASH_COMMAND": { "name": "모델 시작 슬래시 명령 활성화", - "description": "활성화되면 Roo가 워크플로를 실행하기 위해 슬래시 명령을 실행할 수 있습니다." + "description": "활성화되면 Zoo가 워크플로를 실행하기 위해 슬래시 명령을 실행할 수 있습니다." }, "CUSTOM_TOOLS": { "name": "사용자 정의 도구 활성화", - "description": "활성화하면 Roo가 프로젝트의 .roo/tools 디렉터리 또는 전역 도구를 위한 ~/.roo/tools에서 사용자 정의 TypeScript/JavaScript 도구를 로드하고 사용할 수 있습니다. 참고: 이러한 도구는 자동으로 자동 승인됩니다.", + "description": "활성화하면 Zoo가 프로젝트의 .roo/tools 디렉터리 또는 전역 도구를 위한 ~/.roo/tools에서 사용자 정의 TypeScript/JavaScript 도구를 로드하고 사용할 수 있습니다. 참고: 이러한 도구는 자동으로 자동 승인됩니다.", "toolsHeader": "사용 가능한 사용자 정의 도구", "noTools": "로드된 사용자 정의 도구가 없습니다. 프로젝트의 .roo/tools 디렉터리 또는 전역 도구를 위한 ~/.roo/tools에 .ts 또는 .js 파일을 추가하세요.", "refreshButton": "새로고침", @@ -937,7 +937,7 @@ }, "promptCaching": { "label": "프롬프트 캐싱 비활성화", - "description": "체크하면 Roo가 이 모델에 대해 프롬프트 캐싱을 사용하지 않습니다." + "description": "체크하면 Zoo가 이 모델에 대해 프롬프트 캐싱을 사용하지 않습니다." }, "temperature": { "useCustom": "사용자 정의 온도 사용", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 356b6f618b..aa7608b1f3 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -182,7 +182,7 @@ "current": "Huidig" }, "instructions": { - "wantsToFetch": "Roo wil gedetailleerde instructies ophalen om te helpen met de huidige taak" + "wantsToFetch": "Zoo wil gedetailleerde instructies ophalen om te helpen met de huidige taak" }, "fileOperations": { "wantsToRead": "Zoo wil dit bestand lezen", @@ -192,8 +192,8 @@ "wantsToEditOutsideWorkspace": "Zoo wil dit bestand buiten de werkruimte bewerken", "wantsToEditProtected": "Zoo wil een beveiligd configuratiebestand bewerken", "wantsToCreate": "Zoo wil een nieuw bestand aanmaken", - "wantsToSearchReplace": "Roo wil zoeken en vervangen in dit bestand", - "didSearchReplace": "Roo heeft zoeken en vervangen uitgevoerd op dit bestand", + "wantsToSearchReplace": "Zoo wil zoeken en vervangen in dit bestand", + "didSearchReplace": "Zoo heeft zoeken en vervangen uitgevoerd op dit bestand", "wantsToInsert": "Zoo wil inhoud invoegen in dit bestand", "wantsToInsertWithLineNumber": "Zoo wil inhoud invoegen in dit bestand op regel {{lineNumber}}", "wantsToInsertAtEnd": "Zoo wil inhoud toevoegen aan het einde van dit bestand", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 45b92345d9..6664eb3d74 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "U kunt een globale sneltoets voor deze instelling configureren in de voorkeuren van uw IDE.", - "description": "Sta Roo toe om automatisch handelingen uit te voeren zonder goedkeuring. Schakel deze instellingen alleen in als je de AI volledig vertrouwt en de bijbehorende beveiligingsrisico's begrijpt.", + "description": "Sta Zoo toe om automatisch handelingen uit te voeren zonder goedkeuring. Schakel deze instellingen alleen in als je de AI volledig vertrouwt en de bijbehorende beveiligingsrisico's begrijpt.", "enabled": "Auto-goedkeuren ingeschakeld", "toggleAriaLabel": "Automatisch goedkeuren in-/uitschakelen", "disabledAriaLabel": "Automatisch goedkeuren uitgeschakeld - selecteer eerst opties", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 9cf15a939d..505d8b766c 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -187,7 +187,7 @@ "current": "Bieżący" }, "instructions": { - "wantsToFetch": "Roo chce pobrać szczegółowe instrukcje, aby pomóc w bieżącym zadaniu" + "wantsToFetch": "Zoo chce pobrać szczegółowe instrukcje, aby pomóc w bieżącym zadaniu" }, "fileOperations": { "wantsToRead": "Zoo chce przeczytać ten plik", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo chce edytować ten plik poza obszarem roboczym", "wantsToEditProtected": "Zoo chce edytować chroniony plik konfiguracyjny", "wantsToCreate": "Zoo chce utworzyć nowy plik", - "wantsToSearchReplace": "Roo chce wykonać wyszukiwanie i zamianę w tym pliku", - "didSearchReplace": "Roo wykonał wyszukiwanie i zamianę w tym pliku", + "wantsToSearchReplace": "Zoo chce wykonać wyszukiwanie i zamianę w tym pliku", + "didSearchReplace": "Zoo wykonał wyszukiwanie i zamianę w tym pliku", "wantsToInsert": "Zoo chce wstawić zawartość do tego pliku", "wantsToInsertWithLineNumber": "Zoo chce wstawić zawartość do tego pliku w linii {{lineNumber}}", "wantsToInsertAtEnd": "Zoo chce dodać zawartość na końcu tego pliku", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index bf35f445dd..7a7ab3dcb0 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Możesz skonfigurować globalny skrót dla tego ustawienia w preferencjach swojego IDE.", - "description": "Pozwól Roo na automatyczne wykonywanie operacji bez wymagania zatwierdzenia. Włącz te ustawienia tylko jeśli w pełni ufasz AI i rozumiesz związane z tym zagrożenia bezpieczeństwa.", + "description": "Pozwól Zoo na automatyczne wykonywanie operacji bez wymagania zatwierdzenia. Włącz te ustawienia tylko jeśli w pełni ufasz AI i rozumiesz związane z tym zagrożenia bezpieczeństwa.", "enabled": "Auto-zatwierdzanie włączone", "toggleAriaLabel": "Przełącz automatyczne zatwierdzanie", "disabledAriaLabel": "Automatyczne zatwierdzanie wyłączone - najpierw wybierz opcje", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 771d21cd86..45e7b227ce 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -187,7 +187,7 @@ "current": "Atual" }, "instructions": { - "wantsToFetch": "Roo quer buscar instruções detalhadas para ajudar com a tarefa atual" + "wantsToFetch": "Zoo quer buscar instruções detalhadas para ajudar com a tarefa atual" }, "fileOperations": { "wantsToRead": "Zoo quer ler este arquivo", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo quer editar este arquivo fora do espaço de trabalho", "wantsToEditProtected": "Zoo quer editar um arquivo de configuração protegido", "wantsToCreate": "Zoo quer criar um novo arquivo", - "wantsToSearchReplace": "Roo quer realizar busca e substituição neste arquivo", - "didSearchReplace": "Roo realizou busca e substituição neste arquivo", + "wantsToSearchReplace": "Zoo quer realizar busca e substituição neste arquivo", + "didSearchReplace": "Zoo realizou busca e substituição neste arquivo", "wantsToInsert": "Zoo quer inserir conteúdo neste arquivo", "wantsToInsertWithLineNumber": "Zoo quer inserir conteúdo neste arquivo na linha {{lineNumber}}", "wantsToInsertAtEnd": "Zoo quer adicionar conteúdo ao final deste arquivo", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 99d6013a89..bb44c1bcaa 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Você pode configurar um atalho global para esta configuração nas preferências do seu IDE.", - "description": "Permitir que o Roo realize operações automaticamente sem exigir aprovação. Ative essas configurações apenas se confiar totalmente na IA e compreender os riscos de segurança associados.", + "description": "Permitir que o Zoo realize operações automaticamente sem exigir aprovação. Ative essas configurações apenas se confiar totalmente na IA e compreender os riscos de segurança associados.", "enabled": "Aprovação automática habilitada", "toggleAriaLabel": "Alternar aprovação automática", "disabledAriaLabel": "Aprovação automática desativada - selecione as opções primeiro", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 0e1f45ff17..5b8ae0da19 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -182,7 +182,7 @@ "current": "Текущая" }, "instructions": { - "wantsToFetch": "Roo хочет получить подробные инструкции для помощи с текущей задачей" + "wantsToFetch": "Zoo хочет получить подробные инструкции для помощи с текущей задачей" }, "fileOperations": { "wantsToRead": "Zoo хочет прочитать этот файл", @@ -192,8 +192,8 @@ "wantsToEditOutsideWorkspace": "Zoo хочет отредактировать этот файл вне рабочей области", "wantsToEditProtected": "Zoo хочет отредактировать защищённый файл конфигурации", "wantsToCreate": "Zoo хочет создать новый файл", - "wantsToSearchReplace": "Roo хочет выполнить поиск и замену в этом файле", - "didSearchReplace": "Roo выполнил поиск и замену в этом файле", + "wantsToSearchReplace": "Zoo хочет выполнить поиск и замену в этом файле", + "didSearchReplace": "Zoo выполнил поиск и замену в этом файле", "wantsToInsert": "Zoo хочет вставить содержимое в этот файл", "wantsToInsertWithLineNumber": "Zoo хочет вставить содержимое в этот файл на строку {{lineNumber}}", "wantsToInsertAtEnd": "Zoo хочет добавить содержимое в конец этого файла", @@ -242,7 +242,7 @@ "response": "Ответ", "arguments": "Аргументы", "text": { - "rooSaid": "Ру сказал" + "rooSaid": "Zoo сказал" }, "feedback": { "youSaid": "Вы сказали" @@ -382,7 +382,7 @@ "autoApprovedCostLimitReached": { "title": "Достигнут лимит автоматически одобряемых расходов", "button": "Сбросить и продолжить", - "description": "Ру достиг автоматически утвержденного лимита расходов в размере ${{count}}. Хотите сбросить расходы и продолжить выполнение задачи?" + "description": "Zoo достиг автоматически утвержденного лимита расходов в размере ${{count}}. Хотите сбросить расходы и продолжить выполнение задачи?" } }, "codebaseSearch": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index a29da12326..ee02578534 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Вы можете настроить глобальное сочетание клавиш для этого параметра в настройках вашей IDE.", - "description": "Разрешить Roo автоматически выполнять операции без необходимости одобрения. Включайте эти параметры только если полностью доверяете ИИ и понимаете связанные с этим риски безопасности.", + "description": "Разрешить Zoo автоматически выполнять операции без необходимости одобрения. Включайте эти параметры только если полностью доверяете ИИ и понимаете связанные с этим риски безопасности.", "enabled": "Автоодобрение включено", "toggleAriaLabel": "Переключить автоодобрение", "disabledAriaLabel": "Автоодобрение отключено - сначала выберите опции", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 2e9b107008..a5c0bac756 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -187,7 +187,7 @@ "current": "Mevcut" }, "instructions": { - "wantsToFetch": "Roo mevcut göreve yardımcı olmak için ayrıntılı talimatlar almak istiyor" + "wantsToFetch": "Zoo mevcut göreve yardımcı olmak için ayrıntılı talimatlar almak istiyor" }, "fileOperations": { "wantsToRead": "Zoo bu dosyayı okumak istiyor", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo çalışma alanı dışındaki bu dosyayı düzenlemek istiyor", "wantsToEditProtected": "Zoo korumalı bir yapılandırma dosyasını düzenlemek istiyor", "wantsToCreate": "Zoo yeni bir dosya oluşturmak istiyor", - "wantsToSearchReplace": "Roo bu dosyada arama ve değiştirme yapmak istiyor", - "didSearchReplace": "Roo bu dosyada arama ve değiştirme yaptı", + "wantsToSearchReplace": "Zoo bu dosyada arama ve değiştirme yapmak istiyor", + "didSearchReplace": "Zoo bu dosyada arama ve değiştirme yaptı", "wantsToInsert": "Zoo bu dosyaya içerik eklemek istiyor", "wantsToInsertWithLineNumber": "Zoo bu dosyanın {{lineNumber}}. satırına içerik eklemek istiyor", "wantsToInsertAtEnd": "Zoo bu dosyanın sonuna içerik eklemek istiyor", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 13c0b9c37c..e37c154ead 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "IDE tercihlerinizde bu ayar için genel bir kısayol yapılandırabilirsiniz.", - "description": "Roo'nun onay gerektirmeden otomatik olarak işlemler gerçekleştirmesine izin verin. Bu ayarları yalnızca yapay zekaya tamamen güveniyorsanız ve ilgili güvenlik risklerini anlıyorsanız etkinleştirin.", + "description": "Zoo'nun onay gerektirmeden otomatik olarak işlemler gerçekleştirmesine izin verin. Bu ayarları yalnızca yapay zekaya tamamen güveniyorsanız ve ilgili güvenlik risklerini anlıyorsanız etkinleştirin.", "enabled": "Oto-onay etkinleştirildi", "toggleAriaLabel": "Otomatik onayı değiştir", "disabledAriaLabel": "Otomatik onay devre dışı - önce seçenekleri belirleyin", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 7737ea7b75..b5da186e43 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -187,7 +187,7 @@ "current": "Hiện tại" }, "instructions": { - "wantsToFetch": "Roo muốn lấy hướng dẫn chi tiết để hỗ trợ nhiệm vụ hiện tại" + "wantsToFetch": "Zoo muốn lấy hướng dẫn chi tiết để hỗ trợ nhiệm vụ hiện tại" }, "fileOperations": { "wantsToRead": "Zoo muốn đọc tệp này", @@ -197,8 +197,8 @@ "wantsToEditOutsideWorkspace": "Zoo muốn chỉnh sửa tệp này bên ngoài không gian làm việc", "wantsToEditProtected": "Zoo muốn chỉnh sửa tệp cấu hình được bảo vệ", "wantsToCreate": "Zoo muốn tạo một tệp mới", - "wantsToSearchReplace": "Roo muốn thực hiện tìm kiếm và thay thế trong tệp này", - "didSearchReplace": "Roo đã thực hiện tìm kiếm và thay thế trong tệp này", + "wantsToSearchReplace": "Zoo muốn thực hiện tìm kiếm và thay thế trong tệp này", + "didSearchReplace": "Zoo đã thực hiện tìm kiếm và thay thế trong tệp này", "wantsToInsert": "Zoo muốn chèn nội dung vào tệp này", "wantsToInsertWithLineNumber": "Zoo muốn chèn nội dung vào dòng {{lineNumber}} của tệp này", "wantsToInsertAtEnd": "Zoo muốn thêm nội dung vào cuối tệp này", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b59313d47a..c3b40a004a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "Bạn có thể định cấu hình một phím tắt chung cho cài đặt này trong tùy chọn IDE của bạn.", - "description": "Cho phép Roo tự động thực hiện các hoạt động mà không cần phê duyệt. Chỉ bật những cài đặt này nếu bạn hoàn toàn tin tưởng AI và hiểu rõ các rủi ro bảo mật liên quan.", + "description": "Cho phép Zoo tự động thực hiện các hoạt động mà không cần phê duyệt. Chỉ bật những cài đặt này nếu bạn hoàn toàn tin tưởng AI và hiểu rõ các rủi ro bảo mật liên quan.", "enabled": "Phê duyệt tự động đã bật", "toggleAriaLabel": "Chuyển đổi tự động phê duyệt", "disabledAriaLabel": "Tự động phê duyệt bị vô hiệu hóa - hãy chọn các tùy chọn trước", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 3322c53542..63b8ac69fd 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -130,7 +130,7 @@ "title": "模式", "marketplace": "模式市场", "settings": "模式设置", - "description": "专门定制Roo行为的角色。", + "description": "专门定制Zoo行为的角色。", "searchPlaceholder": "搜索模式...", "noResults": "未找到结果" }, @@ -144,7 +144,7 @@ "diffError": { "title": "编辑失败" }, - "troubleMessage": "Roo遇到问题...", + "troubleMessage": "Zoo遇到问题...", "apiRequest": { "title": "API请求", "failed": "API请求失败", @@ -187,7 +187,7 @@ "current": "当前" }, "instructions": { - "wantsToFetch": "Roo 想要获取详细指示以协助当前任务" + "wantsToFetch": "Zoo 想要获取详细指示以协助当前任务" }, "fileOperations": { "wantsToRead": "需要读取文件", @@ -253,8 +253,8 @@ "youSaid": "你说" }, "mcp": { - "wantsToUseTool": "Roo想在{{serverName}} MCP上使用工具", - "wantsToAccessResource": "Roo想访问{{serverName}} MCP服务上的资源" + "wantsToUseTool": "Zoo想在{{serverName}} MCP上使用工具", + "wantsToAccessResource": "Zoo想访问{{serverName}} MCP服务上的资源" }, "modes": { "wantsToSwitch": "即将切换至{{mode}}模式", @@ -263,8 +263,8 @@ "didSwitchWithReason": "已切换至{{mode}}模式(原因:{{reason}})" }, "subtasks": { - "wantsToCreate": "Roo想在{{mode}}模式下创建新子任务", - "wantsToFinish": "Roo想完成此子任务", + "wantsToCreate": "Zoo想在{{mode}}模式下创建新子任务", + "wantsToFinish": "Zoo想完成此子任务", "newTaskContent": "子任务说明", "completionContent": "子任务已完成", "resultContent": "子任务结果", @@ -273,7 +273,7 @@ "goToSubtask": "查看任务" }, "questions": { - "hasQuestion": "Roo有一个问题" + "hasQuestion": "Zoo有一个问题" }, "taskCompleted": "任务完成", "modelResponseIncomplete": "模型响应不完整", @@ -381,7 +381,7 @@ }, "autoApprovedCostLimitReached": { "title": "已达到自动批准的费用限额", - "description": "Roo已经达到了${{count}}的自动批准成本限制。您想重置成本并继续任务吗?", + "description": "Zoo已经达到了${{count}}的自动批准成本限制。您想重置成本并继续任务吗?", "button": "重置并继续" } }, diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index 84255d8868..9d3f9ee9cf 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -9,7 +9,7 @@ "editModesConfig": "模式设置", "editGlobalModes": "修改全局模式", "editProjectModes": "编辑项目模式 (.roomodes)", - "createModeHelpText": "模式是Roo的专属角色,用于定制其行为。<0>了解如何使用模式或<1>自定义模式。", + "createModeHelpText": "模式是Zoo的专属角色,用于定制其行为。<0>了解如何使用模式或<1>自定义模式。", "selectMode": "搜索模式" }, "apiConfiguration": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 1b408875d4..2e8abf435e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -283,7 +283,7 @@ }, "autoApprove": { "toggleShortcut": "您可以在 IDE 首选项中为此设置配置全局快捷方式。", - "description": "允许 Roo 自动执行操作而无需批准。只有在您完全信任 AI 并了解相关安全风险的情况下才启用这些设置。", + "description": "允许 Zoo 自动执行操作而无需批准。只有在您完全信任 AI 并了解相关安全风险的情况下才启用这些设置。", "enabled": "自动批准已启用", "toggleAriaLabel": "切换自动批准", "disabledAriaLabel": "自动批准已禁用 - 请先选择选项", @@ -616,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "错误和重复限制", - "description": "在显示“Roo遇到问题”对话框前允许的连续错误或重复操作次数。设置为 0 可禁用此安全机制(它将永远不会触发)。", + "description": "在显示“Zoo遇到问题”对话框前允许的连续错误或重复操作次数。设置为 0 可禁用此安全机制(它将永远不会触发)。", "unlimitedDescription": "已启用无限重试(自动继续)。对话框将永远不会出现。", "warning": "⚠️ 设置为 0 允许无限重试,这可能会消耗大量 API 使用量" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 3807cd2489..dbea150f29 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -228,7 +228,7 @@ "didLoad": "Zoo 載入了技能" }, "instructions": { - "wantsToFetch": "Roo 想要取得詳細指示以協助目前工作" + "wantsToFetch": "Zoo 想要取得詳細指示以協助目前工作" }, "fileOperations": { "wantsToRead": "Zoo 想要讀取此檔案", diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index b93603f5a6..3f6cb9c54b 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -18,6 +18,8 @@ @import "tailwindcss/theme.css" layer(theme); @import "./preflight.css" layer(base); @import "tailwindcss/utilities.css" layer(utilities); + +@source "../src"; @import "katex/dist/katex.min.css"; @plugin "tailwindcss-animate"; diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 8aebe3f8e5..6ce9bf5245 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -1,4 +1,4 @@ -import type { ProviderSettings, OrganizationAllowList, RouterModels } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, type RouterModels } from "@roo-code/types" // Mock i18next to return translation keys with interpolated values vi.mock("i18next", () => ({ diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 0a812e9d48..b750d8833b 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -11,6 +11,7 @@ import { isDynamicProvider, isFauxProvider, isCustomProvider, + providerIdentifiers, } from "@roo-code/types" export function validateApiConfiguration( @@ -42,117 +43,117 @@ function validateModelsAndKeysProvided( zooCodeIsAuthenticated?: boolean, ): string | undefined { switch (apiConfiguration.apiProvider) { - case "openrouter": + case providerIdentifiers.openrouter: if (!apiConfiguration.openRouterApiKey) { return i18next.t("settings:validation.apiKey") } break - case "requesty": + case providerIdentifiers.requesty: if (!apiConfiguration.requestyApiKey) { return i18next.t("settings:validation.apiKey") } break - case "unbound": + case providerIdentifiers.unbound: if (!apiConfiguration.unboundApiKey) { return i18next.t("settings:validation.apiKey") } break - case "litellm": + case providerIdentifiers.litellm: if (!apiConfiguration.litellmApiKey) { return i18next.t("settings:validation.apiKey") } break - case "anthropic": + case providerIdentifiers.anthropic: if (!apiConfiguration.apiKey) { return i18next.t("settings:validation.apiKey") } break - case "bedrock": + case providerIdentifiers.bedrock: if (!apiConfiguration.awsRegion) { return i18next.t("settings:validation.awsRegion") } break - case "vertex": + case providerIdentifiers.vertex: if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) { return i18next.t("settings:validation.googleCloud") } break - case "gemini": + case providerIdentifiers.gemini: if (!apiConfiguration.geminiApiKey) { return i18next.t("settings:validation.apiKey") } break - case "openai-native": + case providerIdentifiers.openaiNative: if (!apiConfiguration.openAiNativeApiKey) { return i18next.t("settings:validation.apiKey") } break - case "mistral": + case providerIdentifiers.mistral: if (!apiConfiguration.mistralApiKey) { return i18next.t("settings:validation.apiKey") } break - case "openai": + case providerIdentifiers.openai: if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) { return i18next.t("settings:validation.openAi") } break - case "ollama": + case providerIdentifiers.ollama: if (!apiConfiguration.ollamaModelId) { return i18next.t("settings:validation.modelId") } break - case "lmstudio": + case providerIdentifiers.lmstudio: if (!apiConfiguration.lmStudioModelId) { return i18next.t("settings:validation.modelId") } break - case "vscode-lm": + case providerIdentifiers.vscodeLm: if (!apiConfiguration.vsCodeLmModelSelector) { return i18next.t("settings:validation.modelSelector") } break - case "fireworks": + case providerIdentifiers.fireworks: if (!apiConfiguration.fireworksApiKey) { return i18next.t("settings:validation.apiKey") } break - case "friendli": + case providerIdentifiers.friendli: if (!apiConfiguration.friendliApiKey) { return i18next.t("settings:validation.apiKey") } break - case "qwen-code": + case providerIdentifiers.qwenCode: if (!apiConfiguration.qwenCodeOauthPath) { return i18next.t("settings:validation.qwenCodeOauthPath") } break - case "kimi-code": + case providerIdentifiers.kimiCode: if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) { return i18next.t("settings:validation.apiKey") } break - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: if (!apiConfiguration.vercelAiGatewayApiKey) { return i18next.t("settings:validation.apiKey") } break - case "opencode-go": + case providerIdentifiers.opencodeGo: if (!apiConfiguration.opencodeGoApiKey) { return i18next.t("settings:validation.apiKey") } break - case "kenari": + case providerIdentifiers.kenari: if (!apiConfiguration.kenariApiKey) { return i18next.t("settings:validation.apiKey") } break - case "zoo-gateway": + case providerIdentifiers.zooGateway: if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) { return i18next.t("settings:validation.zooGatewaySignIn") } break - case "baseten": + case providerIdentifiers.baseten: if (!apiConfiguration.basetenApiKey) { return i18next.t("settings:validation.apiKey") } @@ -206,7 +207,7 @@ function validateProviderAgainstOrganizationSettings( } function getModelIdForProvider(apiConfiguration: ProviderSettings, provider: ProviderName): string | undefined { - if (provider === "vscode-lm") { + if (provider === providerIdentifiers.vscodeLm) { return apiConfiguration.vsCodeLmModelSelector?.id } @@ -317,7 +318,7 @@ export function validateApiConfigurationExcludingModelErrors( _routerModels?: RouterModels, // Keeping this for compatibility with the old function. organizationAllowList?: OrganizationAllowList, ): string | undefined { - if (apiConfiguration.apiProvider !== "zoo-gateway") { + if (apiConfiguration.apiProvider !== providerIdentifiers.zooGateway) { const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration) if (keysAndIdsPresentErrorMessage) {