diff --git a/.changeset/itchy-moles-thank.md b/.changeset/itchy-moles-thank.md new file mode 100644 index 0000000000..42bb637024 --- /dev/null +++ b/.changeset/itchy-moles-thank.md @@ -0,0 +1,5 @@ +--- +"zoo-code": patch +--- + +Fix bedrock DNS resolution when behind corporate proxy diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 8535881ee5..a05fdb65e5 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -6,7 +6,7 @@ inputs: node-version: description: "Node.js version to use" required: false - default: "20.20.2" + default: "22.23.1" pnpm-version: description: "pnpm version to use" required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cfcb93de39..7960d608eb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -38,14 +38,35 @@ Detail the steps to test your changes. This helps reviewers verify your work. - [ ] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR). - [ ] **Self-Review**: I have performed a thorough self-review of my code. - [ ] **Testing**: New and/or updated tests have been added to cover my changes (if applicable). +- [ ] **Visual Snapshot** (UI changes only): If a user would notice this change at a glance (layout, theme tokens, brand elements, empty/error states), I've added or updated a `*.visual.tsx` snapshot in `webview-ui/`. See `webview-ui/AGENTS.md` → "When a UI change needs a snapshot". - [ ] **Documentation Impact**: I have considered if my changes require documentation updates (see "Documentation Updates" section below). - [ ] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md). -### Screenshots / Videos +### Visual Snapshots + +### Videos (interaction / animation only) + + ### Documentation Updates diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 6dc7b33d9d..21d1f29fff 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -135,11 +135,11 @@ jobs: # lanes for behavioral confidence, but duplicating coverage uploads # there mostly adds Codecov overhead without changing pass/fail # behavior. - # Coverage is uploaded in three separate steps so each LCOV gets the + # Coverage is uploaded in separate steps so each LCOV gets the # correct flag set. Codecov double-counts overlapping lines when a # single upload carries multiple flags whose paths overlap, so the - # two core lanes (which both cover packages/core/src/**) must be - # uploaded individually with their own lane flag. + # core lanes and webview lane must be uploaded individually with + # their own flag. # See https://docs.codecov.com/docs/flags - name: Upload non-core coverage to Codecov if: matrix.upload-coverage @@ -147,13 +147,20 @@ jobs: with: files: >- src/coverage/lcov.info, - webview-ui/coverage/lcov.info, packages/cloud/coverage/lcov.info, packages/telemetry/coverage/lcov.info, apps/cli/coverage/lcov.info disable_search: true flags: ${{ matrix.codecov-flag }} token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload webview JSDOM coverage to Codecov + if: matrix.upload-coverage + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 + with: + files: webview-ui/coverage/lcov.info + disable_search: true + flags: webview-ui + token: ${{ secrets.CODECOV_TOKEN }} - name: Upload core unit coverage to Codecov if: matrix.upload-coverage uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 @@ -170,3 +177,17 @@ jobs: disable_search: true flags: ${{ matrix.codecov-flag }},core-integration token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage reports to GitHub + if: matrix.upload-coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-reports-${{ matrix.name }} + path: | + src/coverage/lcov.info + webview-ui/coverage/lcov.info + packages/cloud/coverage/lcov.info + packages/telemetry/coverage/lcov.info + apps/cli/coverage/lcov.info + packages/core/coverage/unit/lcov.info + packages/core/coverage/integration/lcov.info + retention-days: 7 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/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..6160ea6be0 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,57 @@ +name: Webview Visual Regression + +on: + workflow_dispatch: + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + paths: + - "webview-ui/**" + - "src/shared/**" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/visual-regression.yml" + merge_group: + types: [checks_requested] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + webview-visual: + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble@sha256:9bd26ad900bb5e0f4dee75839e957a89ae89c2b7ab1e76050e559790e946b948 + options: --ipc=host + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + install-args: "--frozen-lockfile" + - name: Run webview visual tests + run: pnpm --filter @roo-code/vscode-webview test:visual + - name: Upload visual test coverage to Codecov + if: always() + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 + with: + files: webview-ui/coverage-ct/lcov.info + disable_search: true + flags: webview-ui-ct + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload visual test artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: webview-visual-regression + path: | + webview-ui/playwright-report + webview-ui/test-results + if-no-files-found: ignore diff --git a/.nvmrc b/.nvmrc index ccc4c6c7f8..f9e7451e77 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.20.2 +22.23.1 diff --git a/.tool-versions b/.tool-versions index 550032bc20..36731124c7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ pnpm 10.8.1 -nodejs 20.20.2 +nodejs 22.23.1 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/CHANGELOG.md b/CHANGELOG.md index 433b11aab5..8a96b42574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Zoo Code Changelog +## [3.72.0] + +### Minor Changes + +- Add the Moonshot provider with live model discovery, streaming, model metadata, and a model picker (PR #857 by @grizmin) +- Add the Kimi Code provider with OAuth device-flow authentication (PR #945 by @taltas) +- Add Claude Opus 5 support across all providers (PR #1010 by @app/zoomote) +- Add Kimi K3 to the Moonshot and OpenCode Go providers (#932 by @navedmerchant, PR #996 by @app/zoomote) +- Add Gemini 3.6 Flash model support (PR #975 by @app/zoomote) +- Add MiniMax-M3 model support (#888 by @RayWinter0816, PR #946 by @app/zoomote) +- Add a safe way to abandon interrupted subtasks by severing stale parent-child links and surfacing delegation status (#559 by @edelauna, PR #935 by @edelauna) +- Add Dart support to codebase indexing (#940 by @WebMad, PR #941 by @WebMad) +- Fix codebase indexing for plain-text files (#931 by @tool-buddy, PR #938 by @WebMad) +- Enable image input for DeepSeek V4 models (#964 by @grizmin, PR #963 by @grizmin) +- Fix ChatGPT OAuth requests for GPT-5.6 Luna being rejected by the Codex backend (PR #889 by @taltas) +- Preserve `reasoning_content` for known reasoning model families when using LiteLLM (#891 by @daewoongoh, PR #899 by @daewoongoh) +- Fix task-history cache invalidation races by routing `invalidate()` and `invalidateAll()` through the task-history lock (#698 by @edelauna, PR #912 by @morgan-coded) +- Fix Settings mode changes by synchronizing the local `cachedState` editing buffer (#914 by @easonLiangWorldedtech, PR #925 by @easonLiangWorldedtech) +- Dismiss the welcome screen after successful Zoo Gateway sign-in (#961 by @JohnCanty, PR #962 by @JamesRobert20) +- Add `CompletePromptOptions` to the `completePrompt` API so callers can configure prompt completion (#615 by @edelauna, PR #901 by @easonLiangWorldedtech) +- Centralize provider identifiers into canonical shared types (#951 by @WebMad, PR #952 by @WebMad) +- Refactor provider categories to use canonical provider identifiers (PR #989 by @WebMad) +- Remove obsolete MCP server-creation translations (#895 by @edelauna, PR #943 by @WebMad) +- Add regression coverage for resuming interrupted subtasks (#566 by @myk1yt, PR #911 by @edelauna) +- Upload coverage reports as GitHub Actions artifacts to simplify CI debugging (PR #939 by @app/zoomote) +- Update `esbuild-wasm` to v0.28.1 (PR #829 by @app/renovate) + ## [3.70.0] ### Minor Changes diff --git a/README.md b/README.md index 5343674b7a..c3ff388c97 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ You can find a quick guide for migrating from Roo Code to Zoo Code in the [Roo→Zoo migration guide](https://docs.zoocode.dev/roo-to-zoo-migration). We plan to try and help users as they transition over, we have our [Reddit](https://www.reddit.com/r/ZooCode) and [Discord](https://discord.gg/VxfP4Vx3gX) for this exact support, so if you are having problems or if you have question, jump on and ask. -## What's New in v3.70.0 +## What's New in v3.72.0 -- **OpenAI GPT-5.6 family** — `Sol`, `Terra`, and `Luna` are now available across both the OpenAI Codex and OpenAI Native provider paths. -- **Grok 4.5 support** — xAI's new flagship model, plus a reasoning-effort format fix that also benefits Grok 4 Mini. -- **Kenari provider support** — a first-class, OpenAI-compatible AI gateway billed in Rupiah covering Claude, GPT, DeepSeek, GLM, Kimi and more. -- Surface the context-compaction button and context window progress bar in the collapsed task header. -- Fix: terminal output loss and premature task completion on cold terminals. -- Fix: image attach for Zoo Gateway and Vercel AI Gateway models now follows live vision-capability tags instead of a static allowlist. -- Dependency and tooling updates. +- **Moonshot and Kimi Code providers** — discover Moonshot models dynamically or sign in to Kimi Code through its OAuth device flow. +- **Latest model support** — use Claude Opus 5 across providers, plus Kimi K3, Gemini 3.6 Flash, and MiniMax-M3. +- **Improved subtask workflows** — abandon interrupted subtasks cleanly, with safer task-history invalidation and better resume coverage. +- **Expanded codebase indexing** — index Dart and plain-text files. +- **Provider reliability** — fixes for GPT-5.6 Luna with ChatGPT OAuth, LiteLLM reasoning content, and DeepSeek V4 image input. +- **Smoother setup and settings** — improved Zoo Gateway sign-in and mode-switch behavior. +- API, provider-type, dependency, localization, and CI improvements.
🌐 Available languages @@ -66,8 +66,7 @@ for this exact support, so if you are having problems or if you have question, j - [Tiếng Việt](locales/vi/README.md) - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) -- ... -
+ --- diff --git a/apps/cli/package.json b/apps/cli/package.json index 04a59a8162..7e0b78827d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,7 +41,7 @@ "@roo-code/config-eslint": "workspace:^", "@trpc/server": "^11.18.0", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/react": "18.3.31", "@vitest/coverage-v8": "4.1.9", "ink-testing-library": "4.0.0", diff --git a/apps/vscode-e2e/package.json b/apps/vscode-e2e/package.json index 2662e7cf07..3b1a3fb18f 100644 --- a/apps/vscode-e2e/package.json +++ b/apps/vscode-e2e/package.json @@ -17,7 +17,7 @@ "@roo-code/types": "workspace:^", "@copilotkit/aimock": "1.35.0", "@types/mocha": "10.0.10", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/vscode": "1.100.0", "@vscode/test-electron": "2.5.2", "dotenv-cli": "11.0.0", diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 772eb3306a..f5dfc813df 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -7,6 +7,8 @@ const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE" const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE" const SUBTASK_INTERRUPT_PARENT_MARKER = "SUBTASK_PARENT_INTERRUPT_RESUME" const SUBTASK_INTERRUPT_CHILD_MARKER = "SUBTASK_CHILD_INTERRUPT_RESUME" +export const SUBTASK_API_HANG_PARENT_MARKER = "SUBTASK_PARENT_API_HANG_INTERRUPT_RESUME" +export const SUBTASK_API_HANG_CHILD_MARKER = "SUBTASK_CHILD_API_HANG_INTERRUPT_RESUME" const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION" const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION" const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE" @@ -16,7 +18,8 @@ const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.` export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.` export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9" -const SUBTASK_FAST_CHILD_PROMPT = `${SUBTASK_FAST_CHILD_MARKER}: Complete immediately with the exact result "Fast child completed".` +export const SUBTASK_FAST_CHILD_RESULT = "Fast child completed" +const SUBTASK_FAST_CHILD_PROMPT = `${SUBTASK_FAST_CHILD_MARKER}: Complete immediately with the exact result "${SUBTASK_FAST_CHILD_RESULT}".` export const SUBTASK_FAST_PARENT_PROMPT = `${SUBTASK_FAST_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_FAST_CHILD_PROMPT}" Do not answer directly.` const SUBTASK_INTERRUPT_CHILD_PROMPT = `${SUBTASK_INTERRUPT_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.` @@ -24,6 +27,20 @@ export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKE export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9" export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed" +const SUBTASK_API_HANG_CHILD_PROMPT = `${SUBTASK_API_HANG_CHILD_MARKER}: Complete with the exact result "Hung child completed".` +export const SUBTASK_API_HANG_PARENT_PROMPT = `${SUBTASK_API_HANG_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_API_HANG_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "API hang parent resumed".` +export const SUBTASK_API_HANG_RESUME_MESSAGE = "Continue after provider hang." +export const SUBTASK_API_HANG_CHILD_RESULT = "Hung child completed" +export const SUBTASK_API_HANG_PARENT_RESULT = "API hang parent resumed" + +// Abandon-subtask scenario (#559) — separate markers to avoid sequenceIndex collisions with the +// interrupted-child-resumes tests above, which exhaust the sequence count for INTERRUPT markers. +const SUBTASK_ABANDON_PARENT_MARKER = "SUBTASK_PARENT_ABANDON_SEVER" +const SUBTASK_ABANDON_CHILD_MARKER = "SUBTASK_CHILD_ABANDON_SEVER" +const SUBTASK_ABANDON_CHILD_PROMPT = `${SUBTASK_ABANDON_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.` +export const SUBTASK_ABANDON_PARENT_PROMPT = `${SUBTASK_ABANDON_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_ABANDON_CHILD_PROMPT}" Do not answer directly.` +export const SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER = "9" + const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".` const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".` export const SUBTASK_XPROFILE_PARENT_PROMPT = `${SUBTASK_XPROFILE_PARENT_MARKER}: First use new_task to create a code-mode subtask with this exact message: "${SUBTASK_XPROFILE_SAME_CHILD_PROMPT}" After it returns, create an ask-mode subtask with the next instructions you receive.` @@ -31,11 +48,47 @@ 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[]) => { const rawRequest = JSON.stringify(req) return expected.every((text) => rawRequest.includes(text)) } +// aimock's `userMessage` matcher only inspects the LAST user message and joins only the +// `type: "text"` content parts (see getTextContent in aimock's router). Fixtures that need +// whole-request exclusions must replicate that scoping inside a predicate so they keep the +// same matching semantics as the bare-regex fixtures they replace. +const lastUserMessageContains = (req: ChatCompletionRequest, text: string) => { + const userMessages = req.messages?.filter((message) => message.role === "user") ?? [] + const last = userMessages.at(-1) + if (!last) return false + const content = + typeof last.content === "string" + ? last.content + : (last.content ?? []) + .filter((part): part is { type: "text"; text: string } => part?.type === "text") + .map((part) => part.text) + .join("") + return content.includes(text) +} + +// reopenParentFromDelegation injects the child result into the resumed parent's history as +// `Subtask completed.\n\nResult:\n`. Matching on this injected prefix (in +// its JSON-serialized form) keeps parent-resume fixtures robust when +// validateAndFixToolResultIds rewrites tool-use ids on resume — matching on the new_task +// tool-call id directly proved flaky (the id can be rewritten, the fixture then misses, and +// a looser child fixture wins and serves the child's response to the parent). +const SUBTASK_RESULT_INJECTION = "completed.\\n\\nResult:" const completionAfterAnswer = (followupId: string, completionId: string) => ({ match: { predicate: (req: ChatCompletionRequest) => @@ -82,25 +135,33 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + // The parent prompt embeds SUBTASK_FAST_CHILD_MARKER verbatim, so parent-resume turns + // can also match a bare substring check (same collision class as #561). Exclude the + // parent marker so those turns fall through to the parent-resume fixture below. mock.addFixture({ match: { - userMessage: new RegExp(SUBTASK_FAST_CHILD_MARKER), + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, SUBTASK_FAST_CHILD_MARKER) && + !requestContains(req, [SUBTASK_FAST_PARENT_MARKER]), }, response: { toolCalls: [ { name: "attempt_completion", - arguments: JSON.stringify({ result: "Fast child completed" }), + arguments: JSON.stringify({ result: SUBTASK_FAST_CHILD_RESULT }), id: "call_subtasks_fast_child_completion_002", }, ], }, }) + // Guard on SUBTASK_RESULT_INJECTION (not the child result text): the child result is + // embedded verbatim in SUBTASK_FAST_PARENT_PROMPT, so it cannot distinguish the parent's + // initial request from its resume turn. mock.addFixture({ match: { predicate: (req: ChatCompletionRequest) => - requestContains(req, [SUBTASK_FAST_PARENT_MARKER, "call_subtasks_fast_parent_new_task_001"]), + requestContains(req, [SUBTASK_FAST_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), }, response: { toolCalls: [ @@ -113,9 +174,15 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + // This fixture is shared by several tests, so sequenceIndex cannot guard it. Exclude + // resume turns via the injected tool_result prefix instead: when the tool_result is + // serialized as role:"tool", the original parent prompt is the last user message again + // and would otherwise re-serve new_task on the parent's resume turn. mock.addFixture({ match: { - userMessage: new RegExp(SUBTASK_PARENT_MARKER), + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, SUBTASK_PARENT_MARKER) && + !requestContains(req, [SUBTASK_RESULT_INJECTION]), }, response: { toolCalls: [ @@ -131,9 +198,12 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + // Same collision guard as the fast-child fixture above: SUBTASK_PARENT_PROMPT embeds + // SUBTASK_CHILD_MARKER verbatim, so parent-resume turns must not match this fixture. mock.addFixture({ match: { - userMessage: new RegExp(SUBTASK_CHILD_MARKER), + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, SUBTASK_CHILD_MARKER) && !requestContains(req, [SUBTASK_PARENT_MARKER]), }, response: { toolCalls: [ @@ -154,7 +224,7 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { predicate: (req: ChatCompletionRequest) => - requestContains(req, [SUBTASK_PARENT_MARKER, "call_subtasks_parent_new_task_001"]), + requestContains(req, [SUBTASK_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), }, response: { toolCalls: [ @@ -167,6 +237,77 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_API_HANG_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_API_HANG_CHILD_PROMPT, + }), + id: "call_api_hang_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: apiHangChildMatch, + sequenceIndex: 0, + }, + // Keep the first child response pending long enough for the e2e test to cancel an in-flight API request. + latency: 15_000, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }), + id: "call_api_hang_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: apiHangChildMatch, + sequenceIndex: 1, + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }), + id: "call_api_hang_child_completion_003", + }, + ], + }, + }) + + // Same as the fast parent-resume fixture: the child result is embedded verbatim in + // SUBTASK_API_HANG_PARENT_PROMPT, so guard on the injected tool_result prefix instead. + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_API_HANG_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_API_HANG_PARENT_RESULT }), + id: "call_api_hang_parent_completion_004", + }, + ], + }, + }) + // Issue #457 sequence: a same-profile child returns first, then the resumed // parent delegates to a child whose mode uses a different API profile. mock.addFixture({ @@ -264,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: { @@ -342,7 +539,7 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { predicate: (req: ChatCompletionRequest) => - requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER, "call_interrupt_parent_new_task_001"]), + requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), }, response: { toolCalls: [ @@ -354,4 +551,67 @@ export function addSubtaskFixtures(mock: InstanceType) { ], }, }) + + // Abandon-subtask scenario (#559) + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_ABANDON_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_ABANDON_CHILD_PROMPT, + }), + id: "call_abandon_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) && + !requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) && + !requestContains(req, ["call_abandon_child_followup_001"]) && + !requestContains(req, [`\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n`]), + }, + response: { + toolCalls: [ + { + name: "ask_followup_question", + arguments: JSON.stringify({ + question: "What is the square root of 81?", + follow_up: [{ text: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }], + }), + id: "call_abandon_child_followup_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + toolResultContains(req, "call_abandon_child_followup_001", [SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) || + requestContains(req, ["call_abandon_child_followup_001", SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) || + requestContains(req, [ + SUBTASK_ABANDON_CHILD_MARKER, + `\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n`, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }), + id: "call_abandon_child_completion_002", + }, + ], + }, + }) } diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 05b8178b1f..9e1d0e83cb 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -5,7 +5,20 @@ 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, + SUBTASK_API_HANG_CHILD_RESULT, + SUBTASK_API_HANG_PARENT_MARKER, + SUBTASK_API_HANG_PARENT_PROMPT, + SUBTASK_API_HANG_PARENT_RESULT, + SUBTASK_API_HANG_RESUME_MESSAGE, SUBTASK_CHILD_FOLLOWUP_ANSWER, + SUBTASK_FAST_CHILD_RESULT, SUBTASK_FAST_PARENT_PROMPT, SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER, SUBTASK_INTERRUPT_PARENT_PROMPT, @@ -17,6 +30,45 @@ import { SUBTASK_XPROFILE_SAME_CHILD_RESULT, } from "../fixtures/subtasks" +type AimockMessageContent = string | Array<{ type?: string; text?: string }> + +type AimockJournalEntry = { + body?: { + messages?: Array<{ + role?: string + content?: AimockMessageContent + }> + } +} + +const messageContentText = (content?: AimockMessageContent) => { + if (typeof content === "string") { + return content + } + + return content?.map((part) => part.text ?? "").join("") ?? "" +} + +const waitForAimockRequestContaining = async (expectedText: string, excludeText?: string) => { + const aimockUrl = process.env.AIMOCK_URL + assert.ok(aimockUrl, "AIMOCK_URL must be set for aimock journal assertions") + + await waitFor(async () => { + const response = await fetch(`${aimockUrl}/__aimock/journal`) + const entries = (await response.json()) as AimockJournalEntry[] + + return entries.some((entry) => { + const messages = entry.body?.messages + if (!messages) return false + const entryText = messages.map((m) => messageContentText(m.content)).join("") + if (excludeText && entryText.includes(excludeText)) return false + return messages.some( + (message) => message.role === "user" && messageContentText(message.content).includes(expectedText), + ) + }) + }) +} + suite("Roo Code Subtasks", function () { setDefaultSuiteTimeout(this) @@ -54,7 +106,8 @@ suite("Roo Code Subtasks", function () { ([taskId, messages]) => taskId !== parentTaskId && messages.some( - ({ say, text }) => say === "completion_result" && text?.trim() === "Fast child completed", + ({ say, text }) => + say === "completion_result" && text?.trim() === SUBTASK_FAST_CHILD_RESULT, ), ), "Immediately-completing child should emit its expected result", @@ -226,7 +279,10 @@ suite("Roo Code Subtasks", function () { const parent = await api.getTaskHistoryItem(parentTaskId) assert.ok(parent, "Parent history item should exist") - assert.strictEqual(parent.status, "active", "Parent status should be 'active' after child completes") + assert.ok( + parent.status === "active" || parent.status === "completed", + `Parent status should be 'active' or 'completed' after child completes (got '${parent.status}')`, + ) assert.strictEqual(parent.awaitingChildId, undefined, "Parent awaitingChildId should be cleared") assert.strictEqual(parent.delegatedToId, undefined, "Parent delegatedToId should be cleared") assert.strictEqual(parent.completedByChildId, childTaskId, "Parent completedByChildId should be the child") @@ -307,15 +363,23 @@ suite("Roo Code Subtasks", function () { await api.cancelCurrentTask() + // Gate on the async settle before asserting the parent never resumed: a spurious + // resume would be an async downstream effect of the cancellation, so a synchronous + // check right after cancelCurrentTask() proves nothing. + await waitFor(() => api.getCurrentTaskStack().at(-1) === spawnedTaskId) + await waitFor( + () => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + ) + assert.ok( messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") === undefined, "Parent task should not have resumed after subtask cancellation", ) - - await waitFor(() => api.getCurrentTaskStack().at(-1) === spawnedTaskId) - await waitFor( - () => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + assert.strictEqual( + messages[parentTaskId]?.find(({ say }) => say === "completion_result"), + undefined, + "Parent must not have completed after subtask cancellation", ) await api.clearCurrentTask() @@ -412,6 +476,113 @@ suite("Roo Code Subtasks", function () { } }) + // Issue #566: a child interrupted while its provider request is still pending + // must keep its parent link when manually resumed and completed. + test("API-hung interrupted child resumes and returns to parent", 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 { + const parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_API_HANG_PARENT_PROMPT, + }) + + let childTaskId: string | undefined + await waitFor(() => { + const stack = api.getCurrentTaskStack() + const current = stack[stack.length - 1] + if (current && current !== parentTaskId) { + childTaskId = current + return true + } + return false + }) + + await waitForAimockRequestContaining(SUBTASK_API_HANG_CHILD_MARKER, SUBTASK_API_HANG_PARENT_MARKER) + + await api.cancelCurrentTask() + + await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId) + await waitFor( + () => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + ) + + const interruptedChild = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be interrupted after manual stop") + assert.strictEqual( + interruptedChild?.parentTaskId, + parentTaskId, + "Interrupted child should retain its parent link before resume", + ) + + const completedParentTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_API_HANG_RESUME_MESSAGE) + return parentTaskId + }, + }) + + assert.strictEqual( + completedParentTaskId, + parentTaskId, + "Parent task should complete after API-hung child resumes and reports back", + ) + assert.strictEqual( + says[childTaskId!] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((text): text is string => !!text), + SUBTASK_API_HANG_CHILD_RESULT, + "Child should complete with its expected result after resume", + ) + assert.strictEqual( + says[parentTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((text): text is string => !!text), + SUBTASK_API_HANG_PARENT_RESULT, + "Parent should resume and complete with its expected result", + ) + + const parent = await api.getTaskHistoryItem(parentTaskId) + assert.notStrictEqual(parent?.status, "delegated", "Parent history should not remain delegated") + assert.strictEqual(parent?.awaitingChildId, undefined, "Parent awaitingChildId should be cleared") + assert.strictEqual(parent?.completedByChildId, childTaskId, "Parent should record completed child") + + const child = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual(child?.status, "completed", "Child history should be completed") + assert.strictEqual(child?.parentTaskId, parentTaskId, "Completed child should still point to parent") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) + } + }) + test("same-profile child returns before a different-profile child", async () => { const api = globalThis.api const says: Record = {} @@ -617,4 +788,300 @@ suite("Roo Code Subtasks", function () { await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) } }) + + // Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves + // the child "interrupted" and the parent "delegated" so the child can still resume and + // report back), abandoning severs the link outright: the parent goes back to "active" and + // the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it. + test("abandoning an interrupted subtask severs the parent-child link", 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 { + const parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_ABANDON_PARENT_PROMPT, + }) + + let childTaskId: string | undefined + await waitFor(() => { + const stack = api.getCurrentTaskStack() + const current = stack[stack.length - 1] + if (current && current !== parentTaskId) { + childTaskId = current + return true + } + return false + }) + + await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false) + await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0) + + // Cancel the child — marked "interrupted", parent stays "delegated". + await api.cancelCurrentTask() + + await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId) + await waitFor( + () => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + ) + + const interruptedChild = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be marked interrupted") + + const delegatedParent = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual(delegatedParent?.status, "delegated", "Parent should still be delegated before abandon") + assert.strictEqual( + delegatedParent?.awaitingChildId, + childTaskId, + "Parent should await the interrupted child", + ) + + // The interrupted child is the live/open task at this point (cancelTask rehydrates + // it onto the stack). Abandon must close that live instance before severing the + // persisted link — otherwise a later save on the still-open child would rebuild + // parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it. + const abandoned = await api.abandonSubtask(childTaskId!) + assert.strictEqual(abandoned, true, "abandonSubtask should report the link was severed") + + await waitFor(() => api.getCurrentTaskStack().at(-1) !== childTaskId) + + const parentAfterAbandon = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual(parentAfterAbandon?.status, "active", "Parent should return to active after abandon") + assert.strictEqual( + parentAfterAbandon?.awaitingChildId, + undefined, + "Parent awaitingChildId should be cleared", + ) + assert.strictEqual(parentAfterAbandon?.delegatedToId, undefined, "Parent delegatedToId should be cleared") + + const childAfterAbandon = await api.getTaskHistoryItem(childTaskId!) + // The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed); + // only its parent/root links are cleared so it can never reattach to the parent again. + assert.strictEqual(childAfterAbandon?.status, "interrupted", "Child status stays interrupted") + assert.strictEqual(childAfterAbandon?.parentTaskId, undefined, "Child parentTaskId should be cleared") + assert.strictEqual(childAfterAbandon?.rootTaskId, undefined, "Child rootTaskId should be cleared") + + // A second abandon call is a no-op since the parent is no longer delegated to this child. + const secondAbandon = await api.abandonSubtask(childTaskId!) + assert.strictEqual(secondAbandon, false, "Second abandonSubtask call should be a no-op") + + // Resume and complete the abandoned child — it must NOT reopen or reattach to the + // parent. Before the abandon fix, a subsequent save on the still-live child could + // silently rewrite its persisted parentTaskId back to the parent; this proves the + // link stays severed all the way through a real resume/save/complete cycle. + // api.resumeTask() re-instantiates the child from history, which re-raises its own + // "resume_task" ask; answering it with the follow-up answer (same pattern the sibling + // "cancelled child completes and reopens parent" test above uses) both resumes the + // task and supplies the answer the re-asked follow-up question is waiting for. + // asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon + // cancellation, so the wait below must look for a NEW one, not just any occurrence. + const askCountBeforeResume = asks[childTaskId!]?.length ?? 0 + await api.resumeTask(childTaskId!) + await waitFor(() => + (asks[childTaskId!] ?? []) + .slice(askCountBeforeResume) + .some(({ type, ask }) => type === "ask" && ask === "resume_task"), + ) + + const completedChildTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER) + return childTaskId! + }, + }) + + assert.strictEqual( + completedChildTaskId, + childTaskId, + "The abandoned child itself should be the task that completes, not the parent", + ) + assert.strictEqual( + says[parentTaskId]?.find(({ say }) => say === "completion_result"), + undefined, + "Parent must never complete/reopen after its abandoned child resumes and completes", + ) + + const parentAfterChildCompletes = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual( + parentAfterChildCompletes?.status, + "active", + "Parent status must remain untouched by the abandoned child's completion", + ) + assert.strictEqual( + parentAfterChildCompletes?.awaitingChildId, + undefined, + "Parent must not start awaiting the abandoned child again", + ) + + const childAfterCompletion = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual( + childAfterCompletion?.parentTaskId, + undefined, + "Child parentTaskId must still be cleared after it completes on its own — " + + "proves the live-instance save did not resurrect the old link", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + 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/codecov.yml b/codecov.yml index 6238bd8f62..7dd22dfdc2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,13 +6,33 @@ coverage: default: target: auto # never regress below current baseline threshold: 1% + webview: + target: auto # webview project ratchet: never drop below current baseline + threshold: 0.5% + flags: + - webview-ui + - webview-ui-ct patch: default: target: 80% # new lines must be 80% covered threshold: 0% + webview-patch: + target: 70% # new lines in webview must be 70% covered + threshold: 0% + flags: + - webview-ui + - webview-ui-ct flag_management: individual_flags: + - name: webview-ui + paths: + - webview-ui/src/ + carryforward: true + - name: webview-ui-ct + paths: + - webview-ui/src/ + carryforward: true - name: core-unit paths: - packages/core/src/ @@ -22,6 +42,18 @@ flag_management: - packages/core/src/ carryforward: true +component_management: + individual_components: + - component_id: webview_components + name: "Webview UI Components" + paths: + - webview-ui/src/components/ + - component_id: webview_state + name: "Webview State & Context" + paths: + - webview-ui/src/context/ + - webview-ui/src/state/ + comment: - layout: "diff, flags" + layout: "diff, flags, components" behavior: default diff --git a/knip.json b/knip.json index 1e0771ef9b..db102031eb 100644 --- a/knip.json +++ b/knip.json @@ -1,8 +1,10 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "ignore": ["**/__tests__/**", "apps/vscode-e2e/**", "scripts/**", "apps/cli/scripts/**"], - "ignoreDependencies": ["lint-staged", "ovsx"], + "ignoreDependencies": ["lint-staged"], "ignoreExportsUsedInFile": true, + "playwright": false, + "playwright-ct": false, "workspaces": { "src": { "entry": ["extension.ts", "extension/api.ts", "workers/countTokens.ts"], @@ -30,7 +32,7 @@ "source-map", "tailwindcss", "tailwindcss-animate", - "vscode" + "monocart-reporter" ] }, "apps/cli": { diff --git a/locales/ca/README.md b/locales/ca/README.md index 1b113deb5e..9853926e20 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -36,15 +36,15 @@ Pots trobar una guia ràpida per passar de Roo Code a Zoo Code a la [guia de migració Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Volem ajudar tant com puguem durant la transició, i per això tens el nostre [Reddit](https://www.reddit.com/r/ZooCode) i [Discord](https://discord.gg/VxfP4Vx3gX) per a aquest suport. Si tens problemes o algun dubte, entra i pregunta. -## Novetats a la v3.70.0 - -- **Família OpenAI GPT-5.6** — `Sol`, `Terra` i `Luna` ja estan disponibles tant a OpenAI Codex com a OpenAI Native. -- **Suport per a Grok 4.5** — el nou model insígnia de xAI, a més d'una correcció del format de l'esforç de raonament que també beneficia Grok 4 Mini. -- **Suport per al proveïdor Kenari** — una passarel·la d'IA de primer nivell, compatible amb OpenAI, facturada en rupies, que cobreix Claude, GPT, DeepSeek, GLM, Kimi i més. -- Mostra el botó de condensació de context i la barra de progrés de la finestra de context a la capçalera de tasca col·lapsada. -- Correcció: pèrdua de sortida del terminal i finalització prematura de tasques en terminals freds. -- Correcció: l'adjunció d'imatges per a Zoo Gateway i Vercel AI Gateway ara segueix les etiquetes de capacitat de visió en directe en lloc d'una llista blanca estàtica. -- Actualitzacions de dependències i eines. +## Novetats a la v3.72.0 + +- **Proveïdors Moonshot i Kimi Code** — descobreix dinàmicament els models de Moonshot o inicia la sessió a Kimi Code mitjançant el flux de dispositiu OAuth. +- **Compatibilitat amb els models més recents** — utilitza Claude Opus 5 amb tots els proveïdors, a més de Kimi K3, Gemini 3.6 Flash i MiniMax-M3. +- **Fluxos de subtasques millorats** — abandona subtasques interrompudes de manera neta, amb una invalidació més segura de l'historial de tasques i una millor cobertura de represa. +- **Indexació de la base de codi ampliada** — indexa fitxers Dart i de text pla. +- **Fiabilitat dels proveïdors** — correccions per a GPT-5.6 Luna amb ChatGPT OAuth, el contingut de raonament de LiteLLM i l'entrada d'imatges de DeepSeek V4. +- **Configuració més fluida** — millores en l'inici de sessió de Zoo Gateway i en el canvi de mode. +- Millores de l'API, els tipus de proveïdor, les dependències, la localització i la CI. ## Què pot fer Zoo Code per TU? diff --git a/locales/de/README.md b/locales/de/README.md index 5a0d2950e8..0706f8d992 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -36,15 +36,15 @@ Eine kurze Anleitung für den Wechsel von Roo Code zu Zoo Code findest du im [Roo→Zoo-Migrationsleitfaden](https://docs.zoocode.dev/roo-to-zoo-migration). Wir wollen Nutzer beim Umstieg so gut wie möglich unterstützen, und genau dafür sind unser [Reddit](https://www.reddit.com/r/ZooCode) und [Discord](https://discord.gg/VxfP4Vx3gX) da. Wenn du Probleme hast oder Fragen auftauchen, komm vorbei und frag nach. -## Neu in v3.70.0 - -- **OpenAI GPT-5.6-Familie** — `Sol`, `Terra` und `Luna` sind jetzt sowohl über OpenAI Codex als auch über OpenAI Native verfügbar. -- **Grok 4.5-Unterstützung** — xAIs neues Flaggschiff-Modell, plus ein Fix für das Reasoning-Effort-Format, von dem auch Grok 4 Mini profitiert. -- **Kenari-Anbieter-Unterstützung** — ein erstklassiges, OpenAI-kompatibles KI-Gateway mit Abrechnung in Rupiah, das Claude, GPT, DeepSeek, GLM, Kimi und mehr abdeckt. -- Zeige den Kontext-Kondensierungs-Button und den Fortschrittsbalken für das Kontextfenster im eingeklappten Task-Header an. -- Fix: Verlust von Terminal-Ausgaben und verfrühter Task-Abschluss bei kalten Terminals. -- Fix: Bild-Anhänge für Zoo Gateway und Vercel AI Gateway folgen jetzt den aktuellen Vision-Fähigkeits-Tags statt einer statischen Positivliste. -- Updates für Abhängigkeiten und Tooling. +## Neu in v3.72.0 + +- **Moonshot- und Kimi-Code-Anbieter** — erkenne Moonshot-Modelle dynamisch oder melde dich über den OAuth-Gerätefluss bei Kimi Code an. +- **Unterstützung für die neuesten Modelle** — nutze Claude Opus 5 anbieterübergreifend sowie Kimi K3, Gemini 3.6 Flash und MiniMax-M3. +- **Verbesserte Unteraufgaben-Workflows** — brich unterbrochene Unteraufgaben sauber ab, mit sichererer Invalidierung des Aufgabenverlaufs und besserer Abdeckung beim Fortsetzen. +- **Erweiterte Codebasis-Indizierung** — indiziere Dart- und Klartextdateien. +- **Zuverlässigere Anbieter** — Korrekturen für GPT-5.6 Luna mit ChatGPT OAuth, LiteLLM-Reasoning-Inhalte und Bildeingaben für DeepSeek V4. +- **Reibungslosere Einrichtung und Einstellungen** — verbessertes Zoo-Gateway-Login und Moduswechselverhalten. +- Verbesserungen an API, Anbietertypen, Abhängigkeiten, Lokalisierung und CI. ## Was kann Zoo Code für DICH tun? diff --git a/locales/es/README.md b/locales/es/README.md index 5015784888..e8e67aec72 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -36,15 +36,15 @@ Puedes encontrar una guía rápida para pasar de Roo Code a Zoo Code en la [guía de migración Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Queremos ayudar a los usuarios durante la transición, y para eso tenemos nuestro [Reddit](https://www.reddit.com/r/ZooCode) y [Discord](https://discord.gg/VxfP4Vx3gX). Si tienes problemas o alguna pregunta, entra y pregúntanos. -## Novedades de la v3.70.0 - -- **Familia OpenAI GPT-5.6** — `Sol`, `Terra` y `Luna` ya están disponibles tanto en OpenAI Codex como en OpenAI Native. -- **Soporte para Grok 4.5** — el nuevo modelo insignia de xAI, además de una corrección del formato de esfuerzo de razonamiento que también beneficia a Grok 4 Mini. -- **Soporte para el proveedor Kenari** — una pasarela de IA de primer nivel, compatible con OpenAI, facturada en rupias, que cubre Claude, GPT, DeepSeek, GLM, Kimi y más. -- Muestra el botón de condensación de contexto y la barra de progreso de la ventana de contexto en el encabezado de tarea colapsado. -- Corrección: pérdida de salida de terminal y finalización prematura de tareas en terminales fríos. -- Corrección: la adjunción de imágenes para Zoo Gateway y Vercel AI Gateway ahora sigue las etiquetas de capacidad de visión en vivo en lugar de una lista blanca estática. -- Actualizaciones de dependencias y herramientas. +## Novedades de la v3.72.0 + +- **Proveedores Moonshot y Kimi Code** — descubre dinámicamente los modelos de Moonshot o inicia sesión en Kimi Code mediante su flujo de dispositivo OAuth. +- **Compatibilidad con los modelos más recientes** — usa Claude Opus 5 con todos los proveedores, además de Kimi K3, Gemini 3.6 Flash y MiniMax-M3. +- **Flujos de subtareas mejorados** — abandona subtareas interrumpidas de forma limpia, con una invalidación más segura del historial de tareas y una mejor cobertura de reanudación. +- **Indexación ampliada de la base de código** — indexa archivos Dart y de texto sin formato. +- **Fiabilidad de los proveedores** — correcciones para GPT-5.6 Luna con ChatGPT OAuth, el contenido de razonamiento de LiteLLM y la entrada de imágenes de DeepSeek V4. +- **Configuración y ajustes más fluidos** — mejoras en el inicio de sesión de Zoo Gateway y en el cambio de modo. +- Mejoras en la API, los tipos de proveedor, las dependencias, la localización y la CI. ## ¿Qué puede hacer Zoo Code por TI? diff --git a/locales/fr/README.md b/locales/fr/README.md index cbc318e898..49ad6618f5 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -36,15 +36,15 @@ Tu peux trouver un guide rapide pour passer de Roo Code à Zoo Code dans le [guide de migration Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). On veut aider au maximum pendant la transition, et notre [Reddit](https://www.reddit.com/r/ZooCode) et notre [Discord](https://discord.gg/VxfP4Vx3gX) sont là pour ça. Si tu rencontres un problème ou si tu as une question, viens demander. -## Nouveautés de la v3.70.0 - -- **Famille OpenAI GPT-5.6** — `Sol`, `Terra` et `Luna` sont désormais disponibles à la fois via OpenAI Codex et OpenAI Native. -- **Prise en charge de Grok 4.5** — le nouveau modèle phare de xAI, ainsi qu'une correction du format de l'effort de raisonnement qui profite aussi à Grok 4 Mini. -- **Prise en charge du fournisseur Kenari** — une passerelle IA de premier ordre, compatible OpenAI, facturée en roupies, couvrant Claude, GPT, DeepSeek, GLM, Kimi et plus encore. -- Affiche le bouton de condensation du contexte et la barre de progression de la fenêtre de contexte dans l'en-tête de tâche réduit. -- Correctif : perte de sortie du terminal et fin de tâche prématurée sur des terminaux froids. -- Correctif : l'ajout d'images pour Zoo Gateway et Vercel AI Gateway suit désormais les balises de capacité de vision en direct au lieu d'une liste blanche statique. -- Mises à jour des dépendances et des outils. +## Nouveautés de la v3.72.0 + +- **Providers Moonshot et Kimi Code** — découvre dynamiquement les modèles Moonshot ou connecte-toi à Kimi Code via son flux d'appareil OAuth. +- **Prise en charge des derniers modèles** — utilise Claude Opus 5 avec tous les providers, ainsi que Kimi K3, Gemini 3.6 Flash et MiniMax-M3. +- **Workflows de sous-tâches améliorés** — abandonne proprement les sous-tâches interrompues, avec une invalidation plus sûre de l'historique et une meilleure couverture de reprise. +- **Indexation étendue de la base de code** — indexe les fichiers Dart et texte brut. +- **Fiabilité des providers** — correctifs pour GPT-5.6 Luna avec ChatGPT OAuth, le contenu de raisonnement LiteLLM et les images DeepSeek V4. +- **Configuration et réglages plus fluides** — connexion à Zoo Gateway et changement de mode améliorés. +- Améliorations de l'API, des types de providers, des dépendances, de la localisation et de la CI. ## Que peut faire Zoo Code pour VOUS ? diff --git a/locales/hi/README.md b/locales/hi/README.md index 745b5d7540..10a442daf5 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -35,15 +35,15 @@ Roo Code से Zoo Code में आने के लिए एक quick guide तुम्हें [Roo→Zoo migration guide](https://docs.zoocode.dev/roo-to-zoo-migration) में मिल जाएगी। We plan to help users as much as possible during the transition, और उसी support के लिए हमारा [Reddit](https://www.reddit.com/r/ZooCode) और [Discord](https://discord.gg/VxfP4Vx3gX) है। अगर तुम्हें कोई problem हो या कोई question हो, आकर पूछो। -## v3.70.0 में नया क्या है - -- **OpenAI GPT-5.6 family** — `Sol`, `Terra`, और `Luna` अब OpenAI Codex और OpenAI Native दोनों provider paths पर उपलब्ध हैं। -- **Grok 4.5 समर्थन** — xAI का नया flagship model, साथ ही एक reasoning-effort format fix जिसका फायदा Grok 4 Mini को भी मिलता है। -- **Kenari provider समर्थन** — एक first-class, OpenAI-compatible AI gateway जो Rupiah में बिल होता है और Claude, GPT, DeepSeek, GLM, Kimi और अधिक को cover करता है। -- Collapsed task header में context-compaction button और context window progress bar दिखाएं। -- फिक्स: cold terminals पर terminal output loss और समय से पहले task completion। -- फिक्स: Zoo Gateway और Vercel AI Gateway models के लिए image attach अब static allowlist के बजाय live vision-capability tags को follow करता है। -- Dependency और tooling अपडेट्स। +## v3.72.0 में नया क्या है + +- **Moonshot और Kimi Code providers** — Moonshot models को dynamically खोजें या OAuth device flow से Kimi Code में sign in करें। +- **नवीनतम model support** — providers पर Claude Opus 5 के साथ Kimi K3, Gemini 3.6 Flash और MiniMax-M3 का उपयोग करें। +- **बेहतर subtask workflows** — interrupted subtasks को साफ़ तौर पर छोड़ें, अधिक सुरक्षित task-history invalidation और बेहतर resume coverage के साथ। +- **विस्तारित codebase indexing** — Dart और plain-text files को index करें। +- **Provider reliability** — ChatGPT OAuth के साथ GPT-5.6 Luna, LiteLLM reasoning content और DeepSeek V4 image input के लिए fixes। +- **अधिक सहज setup और settings** — Zoo Gateway sign-in और mode-switch behavior में सुधार। +- API, provider types, dependencies, localization और CI में सुधार। ## Zoo Code आपके लिए क्या कर सकता है? diff --git a/locales/id/README.md b/locales/id/README.md index a2f995688d..c2b0139290 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -35,15 +35,15 @@ Kamu bisa menemukan panduan singkat untuk berpindah dari Roo Code ke Zoo Code di [panduan migrasi Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Kami ingin membantu pengguna semaksimal mungkin selama masa transisi, dan itulah gunanya [Reddit](https://www.reddit.com/r/ZooCode) dan [Discord](https://discord.gg/VxfP4Vx3gX) kami. Kalau kamu mengalami masalah atau punya pertanyaan, langsung mampir dan tanya. -## Yang Baru di v3.70.0 - -- **Keluarga OpenAI GPT-5.6** — `Sol`, `Terra`, dan `Luna` kini tersedia lewat jalur provider OpenAI Codex maupun OpenAI Native. -- **Dukungan Grok 4.5** — model unggulan baru dari xAI, plus perbaikan format reasoning-effort yang juga bermanfaat untuk Grok 4 Mini. -- **Dukungan provider Kenari** — AI gateway kelas satu yang kompatibel dengan OpenAI, ditagih dalam Rupiah, mencakup Claude, GPT, DeepSeek, GLM, Kimi, dan lainnya. -- Menampilkan tombol pemadatan konteks dan progress bar jendela konteks di header tugas yang diciutkan. -- Perbaikan: hilangnya output terminal dan penyelesaian tugas prematur pada terminal yang dingin (cold terminal). -- Perbaikan: attach gambar untuk model Zoo Gateway dan Vercel AI Gateway kini mengikuti tag kemampuan vision secara langsung, bukan allowlist statis. -- Pembaruan dependensi dan tooling. +## Yang Baru di v3.72.0 + +- **Provider Moonshot dan Kimi Code** — temukan model Moonshot secara dinamis atau masuk ke Kimi Code melalui alur perangkat OAuth. +- **Dukungan model terbaru** — gunakan Claude Opus 5 di berbagai provider, ditambah Kimi K3, Gemini 3.6 Flash, dan MiniMax-M3. +- **Workflow subtask yang lebih baik** — tinggalkan subtask yang terinterupsi dengan bersih, dengan invalidasi riwayat tugas yang lebih aman dan cakupan resume yang lebih baik. +- **Indexing codebase yang lebih luas** — indeks file Dart dan teks biasa. +- **Keandalan provider** — perbaikan untuk GPT-5.6 Luna dengan ChatGPT OAuth, reasoning content LiteLLM, dan input gambar DeepSeek V4. +- **Setup dan pengaturan lebih mulus** — peningkatan login Zoo Gateway dan perilaku pergantian mode. +- Peningkatan API, tipe provider, dependensi, lokalisasi, dan CI. ## Apa yang Bisa Zoo Code Lakukan Untuk ANDA? diff --git a/locales/it/README.md b/locales/it/README.md index 7196b90812..1dd8287b49 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -36,15 +36,15 @@ Puoi trovare una guida rapida per passare da Roo Code a Zoo Code nella [guida alla migrazione Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Vogliamo aiutare gli utenti il più possibile durante la transizione, e per questo abbiamo il nostro [Reddit](https://www.reddit.com/r/ZooCode) e il nostro [Discord](https://discord.gg/VxfP4Vx3gX). Se hai problemi o domande, passa pure e chiedi. -## Novità in v3.70.0 - -- **Famiglia OpenAI GPT-5.6** — `Sol`, `Terra` e `Luna` sono ora disponibili sia tramite OpenAI Codex sia tramite OpenAI Native. -- **Supporto Grok 4.5** — il nuovo modello di punta di xAI, oltre a una correzione del formato reasoning-effort che avvantaggia anche Grok 4 Mini. -- **Supporto provider Kenari** — un AI gateway di prim'ordine, compatibile con OpenAI, fatturato in Rupie, che copre Claude, GPT, DeepSeek, GLM, Kimi e altro ancora. -- Mostra il pulsante di condensazione del contesto e la barra di avanzamento della finestra di contesto nell'intestazione dell'attività compressa. -- Correzione: perdita di output del terminale e completamento prematuro delle attività su terminali freddi. -- Correzione: l'allegato di immagini per i modelli Zoo Gateway e Vercel AI Gateway ora segue i tag di capacità di visione in tempo reale invece di una allowlist statica. -- Aggiornamenti di dipendenze e tooling. +## Novità in v3.72.0 + +- **Provider Moonshot e Kimi Code** — rileva dinamicamente i modelli Moonshot oppure accedi a Kimi Code tramite il flusso dispositivo OAuth. +- **Supporto per i modelli più recenti** — usa Claude Opus 5 con tutti i provider, oltre a Kimi K3, Gemini 3.6 Flash e MiniMax-M3. +- **Workflow delle sottoattività migliorati** — abbandona in modo pulito le sottoattività interrotte, con un'invalidazione più sicura della cronologia e una migliore copertura della ripresa. +- **Indicizzazione della codebase ampliata** — indicizza i file Dart e di testo semplice. +- **Affidabilità dei provider** — correzioni per GPT-5.6 Luna con ChatGPT OAuth, il reasoning content di LiteLLM e l'input di immagini di DeepSeek V4. +- **Configurazione e impostazioni più fluide** — accesso a Zoo Gateway e cambio modalità migliorati. +- Miglioramenti ad API, tipi di provider, dipendenze, localizzazione e CI. ## Cosa può fare Zoo Code per TE? diff --git a/locales/ja/README.md b/locales/ja/README.md index 47ccf716d0..08f4ce1b95 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -35,15 +35,15 @@ Roo Code から Zoo Code へ移行するためのクイックガイドは、[Roo→Zoo 移行ガイド](https://docs.zoocode.dev/roo-to-zoo-migration) で確認できます。移行中のユーザーをできるだけ支援したいと考えていて、そのために [Reddit](https://www.reddit.com/r/ZooCode) と [Discord](https://discord.gg/VxfP4Vx3gX) を用意しています。困ったことや質問があれば、気軽に参加して聞いてください。 -## v3.70.0 の新機能 - -- **OpenAI GPT-5.6 ファミリー** — `Sol`、`Terra`、`Luna` が OpenAI Codex と OpenAI Native の両方のプロバイダーパスで利用可能になりました。 -- **Grok 4.5 サポート** — xAI の新しいフラッグシップモデルに加え、Grok 4 Mini にも恩恵のある reasoning-effort フォーマット修正。 -- **Kenari プロバイダーサポート** — Claude、GPT、DeepSeek、GLM、Kimi などをカバーする、ルピア建てのファーストクラスの OpenAI 互換 AI ゲートウェイ。 -- 折りたたまれたタスクヘッダーにコンテキスト圧縮ボタンとコンテキストウィンドウの進捗バーを表示。 -- 修正: コールドターミナルでのターミナル出力の消失と早すぎるタスク完了。 -- 修正: Zoo Gateway と Vercel AI Gateway モデルの画像添付が、静的な許可リストではなくライブの vision 対応タグに従うように。 -- 依存関係とツールの更新。 +## v3.72.0 の新機能 + +- **Moonshot と Kimi Code プロバイダー** — Moonshot モデルを動的に検出するか、OAuth デバイスフローで Kimi Code にサインインできます。 +- **最新モデルのサポート** — 各プロバイダーで Claude Opus 5 を利用できるほか、Kimi K3、Gemini 3.6 Flash、MiniMax-M3 にも対応しました。 +- **サブタスクワークフローの改善** — タスク履歴をより安全に無効化し、再開時のテスト範囲を強化しながら、中断されたサブタスクを安全に破棄できます。 +- **コードベースインデックスの拡張** — Dart ファイルとプレーンテキストファイルをインデックスできます。 +- **プロバイダーの信頼性向上** — ChatGPT OAuth での GPT-5.6 Luna、LiteLLM の reasoning content、DeepSeek V4 の画像入力を修正しました。 +- **よりスムーズなセットアップと設定** — Zoo Gateway のサインインとモード切り替えを改善しました。 +- API、プロバイダー型、依存関係、ローカライズ、CI を改善しました。 ## Zoo Codeがあなたのためにできること diff --git a/locales/ko/README.md b/locales/ko/README.md index a5c41b6863..2d8441b101 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -34,15 +34,15 @@ Roo Code에서 Zoo Code로 옮겨오는 빠른 가이드는 [Roo→Zoo 마이그레이션 가이드](https://docs.zoocode.dev/roo-to-zoo-migration)에서 확인할 수 있어. 전환하는 동안 사용자들을 최대한 돕고 싶고, 바로 그 지원을 위해 [Reddit](https://www.reddit.com/r/ZooCode)와 [Discord](https://discord.gg/VxfP4Vx3gX)를 운영하고 있어. 문제가 있거나 궁금한 점이 있으면 들어와서 편하게 물어봐. -## v3.70.0의 새로운 기능 - -- **OpenAI GPT-5.6 패밀리** — `Sol`, `Terra`, `Luna`가 이제 OpenAI Codex와 OpenAI Native 프로바이더 경로 모두에서 사용 가능합니다. -- **Grok 4.5 지원** — xAI의 새로운 플래그십 모델과, Grok 4 Mini에도 도움이 되는 reasoning-effort 형식 수정. -- **Kenari 프로바이더 지원** — Claude, GPT, DeepSeek, GLM, Kimi 등을 지원하며 루피아로 청구되는 일류 OpenAI 호환 AI 게이트웨이. -- 접힌 작업 헤더에 컨텍스트 압축 버튼과 컨텍스트 윈도우 진행률 표시줄을 표시합니다. -- 수정: 콜드 터미널에서의 터미널 출력 손실 및 조기 작업 완료 문제. -- 수정: Zoo Gateway 및 Vercel AI Gateway 모델의 이미지 첨부가 이제 정적 허용 목록 대신 실시간 vision 지원 태그를 따릅니다. -- 의존성 및 툴링 업데이트. +## v3.72.0의 새로운 기능 + +- **Moonshot 및 Kimi Code 프로바이더** — Moonshot 모델을 동적으로 검색하거나 OAuth 기기 흐름으로 Kimi Code에 로그인하세요. +- **최신 모델 지원** — 여러 프로바이더에서 Claude Opus 5를 사용하고 Kimi K3, Gemini 3.6 Flash, MiniMax-M3도 이용하세요. +- **개선된 하위 작업 워크플로우** — 더 안전한 작업 기록 무효화와 향상된 재개 테스트 범위로 중단된 하위 작업을 깔끔하게 포기하세요. +- **확장된 코드베이스 인덱싱** — Dart 및 일반 텍스트 파일을 인덱싱하세요. +- **프로바이더 안정성** — ChatGPT OAuth의 GPT-5.6 Luna, LiteLLM reasoning content, DeepSeek V4 이미지 입력 문제를 수정했습니다. +- **더 원활한 설정** — Zoo Gateway 로그인과 모드 전환 동작을 개선했습니다. +- API, 프로바이더 유형, 의존성, 현지화 및 CI 개선. ## Zoo Code가 당신을 위해 무엇을 할 수 있을까요? diff --git a/locales/nl/README.md b/locales/nl/README.md index 7ed26a5495..6ce7a2febb 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -36,15 +36,15 @@ Je vindt een korte handleiding voor de overstap van Roo Code naar Zoo Code in de [Roo→Zoo-migratiegids](https://docs.zoocode.dev/roo-to-zoo-migration). We willen gebruikers zo goed mogelijk helpen tijdens de overgang, en precies daarvoor zijn onze [Reddit](https://www.reddit.com/r/ZooCode) en [Discord](https://discord.gg/VxfP4Vx3gX) er. Als je ergens tegenaan loopt of vragen hebt, kom langs en vraag het. -## Nieuw in v3.70.0 - -- **OpenAI GPT-5.6-familie** — `Sol`, `Terra` en `Luna` zijn nu beschikbaar via zowel OpenAI Codex als OpenAI Native. -- **Grok 4.5-ondersteuning** — het nieuwe vlaggenschipmodel van xAI, plus een fix voor het reasoning-effort-formaat waar ook Grok 4 Mini baat bij heeft. -- **Kenari-providerondersteuning** — een eersteklas, OpenAI-compatibele AI-gateway die in Roepia factureert en Claude, GPT, DeepSeek, GLM, Kimi en meer dekt. -- Toon de contextcompressie-knop en de voortgangsbalk van het contextvenster in de ingeklapte taakheader. -- Fix: verlies van terminaluitvoer en voortijdige taakvoltooiing bij koude terminals. -- Fix: afbeeldingen bijvoegen voor Zoo Gateway- en Vercel AI Gateway-modellen volgt nu live vision-capaciteitstags in plaats van een statische allowlist. -- Updates van dependencies en tooling. +## Nieuw in v3.72.0 + +- **Moonshot- en Kimi Code-providers** — ontdek Moonshot-modellen dynamisch of meld je aan bij Kimi Code via de OAuth-apparaatstroom. +- **Ondersteuning voor de nieuwste modellen** — gebruik Claude Opus 5 bij verschillende providers, plus Kimi K3, Gemini 3.6 Flash en MiniMax-M3. +- **Verbeterde subtaakworkflows** — breek onderbroken subtaken netjes af, met veiligere invalidatie van taakgeschiedenis en betere hervattingsdekking. +- **Uitgebreide codebase-indexering** — indexeer Dart- en plattetekstbestanden. +- **Betrouwbaardere providers** — fixes voor GPT-5.6 Luna met ChatGPT OAuth, LiteLLM-reasoningcontent en DeepSeek V4-beeldinvoer. +- **Soepelere installatie en instellingen** — verbeterde Zoo Gateway-aanmelding en moduswisseling. +- Verbeteringen aan API, providertypen, dependencies, lokalisatie en CI. ## Wat kan Zoo Code voor JOU doen? diff --git a/locales/pl/README.md b/locales/pl/README.md index cc5473b330..83b898eb13 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -34,15 +34,15 @@ Szybki przewodnik po przejściu z Roo Code do Zoo Code znajdziesz w [przewodniku migracji Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Chcemy jak najlepiej pomagać użytkownikom w czasie przejścia i właśnie do tego służą nasze [Reddit](https://www.reddit.com/r/ZooCode) oraz [Discord](https://discord.gg/VxfP4Vx3gX). Jeśli masz problem albo pytanie, wpadaj i pytaj. -## Nowości w v3.70.0 - -- **Rodzina OpenAI GPT-5.6** — `Sol`, `Terra` i `Luna` są teraz dostępne zarówno w ścieżce providera OpenAI Codex, jak i OpenAI Native. -- **Obsługa Grok 4.5** — nowy flagowy model xAI, plus poprawka formatu reasoning-effort, z której korzysta też Grok 4 Mini. -- **Obsługa providera Kenari** — pierwszorzędna, kompatybilna z OpenAI bramka AI rozliczana w rupiach, obejmująca Claude, GPT, DeepSeek, GLM, Kimi i inne. -- Wyświetlanie przycisku kondensacji kontekstu i paska postępu okna kontekstu w zwiniętym nagłówku zadania. -- Poprawka: utrata danych wyjściowych terminala i przedwczesne kończenie zadań na zimnych terminalach. -- Poprawka: dołączanie obrazów dla modeli Zoo Gateway i Vercel AI Gateway korzysta teraz z bieżących tagów możliwości vision zamiast statycznej listy dozwolonych. -- Aktualizacje zależności i narzędzi. +## Nowości w v3.72.0 + +- **Providerzy Moonshot i Kimi Code** — dynamicznie wykrywaj modele Moonshot lub zaloguj się do Kimi Code przez przepływ urządzenia OAuth. +- **Obsługa najnowszych modeli** — korzystaj z Claude Opus 5 u różnych providerów, a także z Kimi K3, Gemini 3.6 Flash i MiniMax-M3. +- **Ulepszone workflow podzadań** — bezpiecznie porzucaj przerwane podzadania dzięki bezpieczniejszej invalidacji historii i lepszemu pokryciu wznawiania. +- **Rozszerzone indeksowanie bazy kodu** — indeksuj pliki Dart i pliki tekstowe. +- **Niezawodność providerów** — poprawki dla GPT-5.6 Luna z ChatGPT OAuth, reasoning content LiteLLM i obrazów DeepSeek V4. +- **Płynniejsza konfiguracja i ustawienia** — ulepszone logowanie do Zoo Gateway i przełączanie trybów. +- Ulepszenia API, typów providerów, zależności, lokalizacji i CI. ## Co Zoo Code może zrobić dla CIEBIE? diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index 90f94199cb..931255ae80 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -36,15 +36,15 @@ Você encontra um guia rápido para migrar do Roo Code para o Zoo Code no [guia de migração Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Queremos ajudar os usuários durante essa transição da melhor forma possível, e é exatamente para isso que temos nosso [Reddit](https://www.reddit.com/r/ZooCode) e nosso [Discord](https://discord.gg/VxfP4Vx3gX). Se você tiver algum problema ou dúvida, apareça por lá e pergunte. -## Novidades na v3.70.0 - -- **Família OpenAI GPT-5.6** — `Sol`, `Terra` e `Luna` já estão disponíveis tanto pelo OpenAI Codex quanto pelo OpenAI Native. -- **Suporte a Grok 4.5** — o novo modelo principal da xAI, além de uma correção no formato de reasoning-effort que também beneficia o Grok 4 Mini. -- **Suporte ao provedor Kenari** — um gateway de IA de primeira linha, compatível com OpenAI, cobrado em rupias, cobrindo Claude, GPT, DeepSeek, GLM, Kimi e mais. -- Exibe o botão de condensação de contexto e a barra de progresso da janela de contexto no cabeçalho de tarefa recolhido. -- Correção: perda de saída do terminal e conclusão prematura de tarefas em terminais frios. -- Correção: o anexo de imagens para os modelos Zoo Gateway e Vercel AI Gateway agora segue as tags de capacidade de visão em tempo real, em vez de uma allowlist estática. -- Atualizações de dependências e ferramentas. +## Novidades na v3.72.0 + +- **Providers Moonshot e Kimi Code** — descubra dinamicamente os modelos Moonshot ou entre no Kimi Code pelo fluxo de dispositivo OAuth. +- **Suporte aos modelos mais recentes** — use Claude Opus 5 em todos os providers, além de Kimi K3, Gemini 3.6 Flash e MiniMax-M3. +- **Workflows de subtarefas aprimorados** — abandone subtarefas interrompidas de forma limpa, com invalidação mais segura do histórico e melhor cobertura de retomada. +- **Indexação ampliada da base de código** — indexe arquivos Dart e de texto simples. +- **Confiabilidade dos providers** — correções para GPT-5.6 Luna com ChatGPT OAuth, reasoning content do LiteLLM e entrada de imagens do DeepSeek V4. +- **Configuração e ajustes mais fluidos** — melhorias no login do Zoo Gateway e na troca de modo. +- Melhorias em API, tipos de provider, dependências, localização e CI. ## O que o Zoo Code pode fazer por VOCÊ? diff --git a/locales/ru/README.md b/locales/ru/README.md index 35e2541ce8..4d1c716985 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -36,15 +36,15 @@ Короткое руководство по переходу с Roo Code на Zoo Code можно найти в [гайде по миграции Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Мы хотим как можно лучше помочь пользователям во время перехода, и именно для этого у нас есть [Reddit](https://www.reddit.com/r/ZooCode) и [Discord](https://discord.gg/VxfP4Vx3gX). Если у тебя возникнут проблемы или вопросы, заходи и спрашивай. -## Что нового в v3.70.0 - -- **Семейство OpenAI GPT-5.6** — `Sol`, `Terra` и `Luna` теперь доступны как через OpenAI Codex, так и через OpenAI Native. -- **Поддержка Grok 4.5** — новая флагманская модель xAI, а также исправление формата reasoning-effort, от которого также выигрывает Grok 4 Mini. -- **Поддержка провайдера Kenari** — первоклассный, совместимый с OpenAI ИИ-шлюз с оплатой в рупиях, охватывающий Claude, GPT, DeepSeek, GLM, Kimi и другие модели. -- Отображение кнопки сжатия контекста и индикатора заполнения контекстного окна в свёрнутом заголовке задачи. -- Исправление: потеря вывода терминала и преждевременное завершение задачи на «холодных» терминалах. -- Исправление: прикрепление изображений для моделей Zoo Gateway и Vercel AI Gateway теперь следует актуальным тегам поддержки vision вместо статического allowlist. -- Обновления зависимостей и инструментов. +## Что нового в v3.72.0 + +- **Провайдеры Moonshot и Kimi Code** — динамически находите модели Moonshot или входите в Kimi Code через OAuth-поток для устройств. +- **Поддержка новейших моделей** — используйте Claude Opus 5 у всех провайдеров, а также Kimi K3, Gemini 3.6 Flash и MiniMax-M3. +- **Улучшенные рабочие процессы подзадач** — безопасно завершайте прерванные подзадачи благодаря более надёжной инвалидации истории и расширенному тестированию возобновления. +- **Расширенная индексация кодовой базы** — индексируйте файлы Dart и обычные текстовые файлы. +- **Надёжность провайдеров** — исправления GPT-5.6 Luna с ChatGPT OAuth, reasoning content LiteLLM и ввода изображений DeepSeek V4. +- **Более удобные настройки** — улучшены доступ к Zoo Gateway и переключение режимов. +- Улучшения API, типов провайдеров, зависимостей, локализации и CI. ## Что Zoo Code может сделать для ВАС? diff --git a/locales/tr/README.md b/locales/tr/README.md index 3dd0413340..6b2e8a7f4e 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -36,15 +36,15 @@ Roo Code'dan Zoo Code'a geçmek için hızlı bir rehberi [Roo→Zoo geçiş rehberinde](https://docs.zoocode.dev/roo-to-zoo-migration) bulabilirsin. Geçiş sürecinde kullanıcılara elimizden geldiğince yardımcı olmak istiyoruz ve bunun için [Reddit](https://www.reddit.com/r/ZooCode) ile [Discord](https://discord.gg/VxfP4Vx3gX) topluluklarımız var. Bir sorun yaşarsan ya da soruların olursa gel ve sor. -## v3.70.0'daki Yenilikler - -- **OpenAI GPT-5.6 ailesi** — `Sol`, `Terra` ve `Luna` artık hem OpenAI Codex hem de OpenAI Native sağlayıcı yollarında kullanılabiliyor. -- **Grok 4.5 desteği** — xAI'nin yeni amiral gemisi modeli, ayrıca Grok 4 Mini'ye de fayda sağlayan bir reasoning-effort format düzeltmesi. -- **Kenari sağlayıcı desteği** — Claude, GPT, DeepSeek, GLM, Kimi ve daha fazlasını kapsayan, Rupi ile faturalandırılan, OpenAI uyumlu, birinci sınıf bir AI ağ geçidi. -- Daraltılmış görev başlığında bağlam yoğunlaştırma düğmesi ve bağlam penceresi ilerleme çubuğunu göster. -- Düzeltme: soğuk terminallerde terminal çıktısı kaybı ve erken görev tamamlanması. -- Düzeltme: Zoo Gateway ve Vercel AI Gateway modelleri için görsel ekleme artık statik bir izin listesi yerine canlı görme yeteneği etiketlerini takip ediyor. -- Bağımlılık ve araç güncellemeleri. +## v3.72.0'daki Yenilikler + +- **Moonshot ve Kimi Code provider'ları** — Moonshot modellerini dinamik olarak keşfet veya OAuth cihaz akışıyla Kimi Code'da oturum aç. +- **En yeni model desteği** — provider'lar genelinde Claude Opus 5'in yanı sıra Kimi K3, Gemini 3.6 Flash ve MiniMax-M3'ü kullan. +- **Geliştirilmiş alt görev iş akışları** — daha güvenli görev geçmişi geçersiz kılma ve daha iyi devam ettirme kapsamıyla kesintiye uğramış alt görevleri temiz biçimde bırak. +- **Genişletilmiş kod tabanı indeksleme** — Dart ve düz metin dosyalarını indeksle. +- **Provider güvenilirliği** — ChatGPT OAuth ile GPT-5.6 Luna, LiteLLM reasoning content ve DeepSeek V4 görsel girdisi için düzeltmeler. +- **Daha akıcı kurulum ve ayarlar** — Zoo Gateway oturum açma ve mod değiştirme davranışı iyileştirildi. +- API, provider türleri, bağımlılıklar, yerelleştirme ve CI iyileştirmeleri. ## Zoo Code SİZİN İçin Ne Yapabilir? diff --git a/locales/vi/README.md b/locales/vi/README.md index 86740a8795..10e493d789 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -35,15 +35,15 @@ Bạn có thể xem hướng dẫn nhanh để chuyển từ Roo Code sang Zoo Code trong [hướng dẫn chuyển đổi Roo→Zoo](https://docs.zoocode.dev/roo-to-zoo-migration). Chúng tôi muốn hỗ trợ người dùng nhiều nhất có thể trong quá trình chuyển đổi, và đó chính là lý do chúng tôi có [Reddit](https://www.reddit.com/r/ZooCode) và [Discord](https://discord.gg/VxfP4Vx3gX). Nếu bạn gặp vấn đề hoặc có câu hỏi, cứ vào hỏi nhé. -## Điểm mới trong v3.70.0 - -- **Dòng OpenAI GPT-5.6** — `Sol`, `Terra`, và `Luna` hiện đã có sẵn trên cả hai đường nhà cung cấp OpenAI Codex và OpenAI Native. -- **Hỗ trợ Grok 4.5** — model chủ lực mới của xAI, cùng với bản sửa lỗi định dạng reasoning-effort cũng mang lại lợi ích cho Grok 4 Mini. -- **Hỗ trợ nhà cung cấp Kenari** — một cổng AI hạng nhất, tương thích OpenAI, tính phí bằng Rupiah, bao phủ Claude, GPT, DeepSeek, GLM, Kimi và nhiều hơn nữa. -- Hiển thị nút cô đọng ngữ cảnh và thanh tiến trình cửa sổ ngữ cảnh trong tiêu đề tác vụ đã thu gọn. -- Sửa lỗi: mất đầu ra terminal và hoàn thành tác vụ quá sớm trên các terminal nguội. -- Sửa lỗi: đính kèm hình ảnh cho các model Zoo Gateway và Vercel AI Gateway giờ đây tuân theo các thẻ khả năng vision trực tiếp thay vì danh sách cho phép tĩnh. -- Cập nhật phụ thuộc và công cụ. +## Điểm mới trong v3.72.0 + +- **Provider Moonshot và Kimi Code** — khám phá động các model Moonshot hoặc đăng nhập Kimi Code qua luồng thiết bị OAuth. +- **Hỗ trợ model mới nhất** — dùng Claude Opus 5 trên tất cả provider, cùng với Kimi K3, Gemini 3.6 Flash và MiniMax-M3. +- **Workflow tác vụ con được cải thiện** — từ bỏ gọn gàng các tác vụ con bị gián đoạn, với cơ chế vô hiệu hóa lịch sử an toàn hơn và phạm vi kiểm thử tiếp tục tốt hơn. +- **Mở rộng lập chỉ mục codebase** — lập chỉ mục file Dart và file văn bản thuần. +- **Độ tin cậy của provider** — sửa lỗi GPT-5.6 Luna với ChatGPT OAuth, reasoning content của LiteLLM và đầu vào hình ảnh DeepSeek V4. +- **Thiết lập và cài đặt mượt mà hơn** — cải thiện đăng nhập Zoo Gateway và hành vi chuyển chế độ. +- Cải tiến API, kiểu provider, phụ thuộc, bản địa hóa và CI. ## Zoo Code có thể làm gì cho BẠN? diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index 9a3e44c155..4bc89bacb4 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -32,15 +32,15 @@ 你可以在 [Roo→Zoo 迁移指南](https://docs.zoocode.dev/roo-to-zoo-migration) 中找到从 Roo Code 迁移到 Zoo Code 的快速说明。我们希望在大家迁移过程中尽可能提供帮助,这也是我们设立 [Reddit](https://www.reddit.com/r/ZooCode) 和 [Discord](https://discord.gg/VxfP4Vx3gX) 社区的原因。如果你遇到问题或有任何疑问,欢迎加入后直接提问。 -## v3.70.0 新增内容 - -- **OpenAI GPT-5.6 系列** — `Sol`、`Terra` 和 `Luna` 现已在 OpenAI Codex 和 OpenAI Native 两条提供商路径上可用。 -- **Grok 4.5 支持** — xAI 的全新旗舰模型,以及一项同样惠及 Grok 4 Mini 的 reasoning-effort 格式修复。 -- **Kenari 提供商支持** — 一个一流的、兼容 OpenAI 的 AI 网关,以卢比计费,覆盖 Claude、GPT、DeepSeek、GLM、Kimi 等更多模型。 -- 在折叠的任务标题中显示上下文压缩按钮和上下文窗口进度条。 -- 修复:在冷终端上出现的终端输出丢失和任务过早完成的问题。 -- 修复:Zoo Gateway 和 Vercel AI Gateway 模型的图片附加功能现在会跟随实时的 vision 能力标签,而不是静态的白名单。 -- 依赖和工具更新。 +## v3.72.0 新增内容 + +- **Moonshot 和 Kimi Code 提供商** — 动态发现 Moonshot 模型,或使用 OAuth 设备流程登录 Kimi Code。 +- **最新模型支持** — 跨提供商使用 Claude Opus 5,以及 Kimi K3、Gemini 3.6 Flash 和 MiniMax-M3。 +- **改进的子任务工作流** — 干净地放弃中断的子任务,并通过更安全的任务历史失效机制和更完善的恢复测试提升可靠性。 +- **扩展代码库索引** — 为 Dart 和纯文本文件建立索引。 +- **提供商可靠性** — 修复 ChatGPT OAuth 下的 GPT-5.6 Luna、LiteLLM reasoning content 和 DeepSeek V4 图片输入问题。 +- **更顺畅的设置体验** — 改进 Zoo Gateway 登录和模式切换行为。 +- API、提供商类型、依赖、本地化和 CI 改进。 ## Zoo Code 能为您做什么? diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index 4bced1aa72..cddf23cccb 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -32,15 +32,15 @@ 你可以在 [Roo→Zoo 遷移指南](https://docs.zoocode.dev/roo-to-zoo-migration) 中找到從 Roo Code 遷移到 Zoo Code 的快速說明。我們希望在大家轉移過程中盡可能提供協助,這也是我們設立 [Reddit](https://www.reddit.com/r/ZooCode) 和 [Discord](https://discord.gg/VxfP4Vx3gX) 社群的原因。如果你遇到問題或有任何疑問,歡迎加入後直接提問。 -## v3.70.0 新功能 - -- **OpenAI GPT-5.6 系列** — `Sol`、`Terra` 和 `Luna` 現已在 OpenAI Codex 與 OpenAI Native 兩條供應商路徑上提供。 -- **Grok 4.5 支援** — xAI 全新的旗艦模型,以及一項同樣使 Grok 4 Mini 受惠的 reasoning-effort 格式修正。 -- **Kenari 供應商支援** — 一個一流的、相容 OpenAI 的 AI 閘道,以印尼盾計費,涵蓋 Claude、GPT、DeepSeek、GLM、Kimi 等更多模型。 -- 在摺疊的任務標頭中顯示內容壓縮按鈕與內容視窗進度條。 -- 修正:冷終端機上發生的終端機輸出遺失與任務過早完成問題。 -- 修正:Zoo Gateway 與 Vercel AI Gateway 模型的圖片附加功能現在會依循即時的 vision 能力標籤,而非靜態的白名單。 -- 相依套件與工具更新。 +## v3.72.0 新功能 + +- **Moonshot 與 Kimi Code 供應商** — 動態探索 Moonshot 模型,或使用 OAuth 裝置流程登入 Kimi Code。 +- **最新模型支援** — 跨供應商使用 Claude Opus 5,以及 Kimi K3、Gemini 3.6 Flash 與 MiniMax-M3。 +- **改善的子任務工作流程** — 乾淨地放棄中斷的子任務,並透過更安全的任務歷史失效機制與更完整的恢復測試提升可靠性。 +- **擴充程式碼庫索引** — 為 Dart 與純文字檔案建立索引。 +- **供應商可靠性** — 修正 ChatGPT OAuth 下的 GPT-5.6 Luna、LiteLLM reasoning content 與 DeepSeek V4 圖片輸入問題。 +- **更順暢的設定體驗** — 改善 Zoo Gateway 登入與模式切換行為。 +- API、供應商類型、相依套件、本地化與 CI 改善。 ## Zoo Code 能為您做什麼? diff --git a/package.json b/package.json index 2c1dd77639..b2ab5b7173 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "roo-code", "packageManager": "pnpm@10.8.1", "engines": { - "node": "20.20.2" + "node": "22.23.1" }, "scripts": { "preinstall": "node scripts/bootstrap.mjs", @@ -30,7 +30,7 @@ "devDependencies": { "@changesets/cli": "2.31.0", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@vscode/vsce": "3.9.2", "esbuild": "0.28.1", "eslint": "9.39.4", diff --git a/packages/build/package.json b/packages/build/package.json index e0175a38d3..f9d5d352ff 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "vitest": "4.1.9" } } diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 952e823eec..b2183d6253 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -197,15 +197,23 @@ function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void { ] for (const { src, dest } of filesToCopy) { - fs.copyFileSync(src, dest) - - // Make CLI executable. if (src.endsWith("esbuild")) { + // Read the esbuild binary, patch it to prevent the V8 crash (crbug.com/1201626) + // by eagerly initializing process.stdout.fd and process.stderr.fd on line 2 + // before WebAssembly execution takes place. + let content = fs.readFileSync(src, "utf8") + content = content.replace( + /^(#!.*$)/m, + "$1\n// Eagerly initialize stdout/stderr to avoid V8 crash (crbug.com/1201626) inside WASM stack\nconst _ = [process.stdout.fd, process.stderr.fd];", + ) + fs.writeFileSync(dest, content) try { fs.chmodSync(dest, 0o755) } catch { // Ignore chmod errors on Windows. } + } else { + fs.copyFileSync(src, dest) } } diff --git a/packages/cloud/package.json b/packages/cloud/package.json index c6cb23c2c0..8757262bb2 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/vscode": "1.100.0", "globals": "16.3.0", "@vitest/coverage-v8": "4.1.9", diff --git a/packages/config-eslint/base.js b/packages/config-eslint/base.js index 6a15f09c03..6f579e2f97 100644 --- a/packages/config-eslint/base.js +++ b/packages/config-eslint/base.js @@ -2,8 +2,6 @@ import js from "@eslint/js" import eslintConfigPrettier from "eslint-config-prettier" import turboPlugin from "eslint-plugin-turbo" import tseslint from "typescript-eslint" -import onlyWarn from "eslint-plugin-only-warn" - /** * A shared ESLint configuration for the repository. * @@ -21,11 +19,6 @@ export const config = [ "turbo/no-undeclared-env-vars": "off", }, }, - { - plugins: { - onlyWarn, - }, - }, { ignores: ["dist/**"], }, diff --git a/packages/config-eslint/package.json b/packages/config-eslint/package.json index 8c10849f33..8c01525543 100644 --- a/packages/config-eslint/package.json +++ b/packages/config-eslint/package.json @@ -12,7 +12,6 @@ "@next/eslint-plugin-next": "15.5.20", "eslint": "9.39.4", "eslint-config-prettier": "10.1.8", - "eslint-plugin-only-warn": "1.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-turbo": "2.10.0", diff --git a/packages/core/package.json b/packages/core/package.json index 16883a6430..741a71704a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.9" } diff --git a/packages/ipc/package.json b/packages/ipc/package.json index d79e8b5900..374c4240b6 100644 --- a/packages/ipc/package.json +++ b/packages/ipc/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/node-ipc": "9.2.3" } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index c7be0214fe..6c45195f6c 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/vscode": "1.100.0", "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.9" diff --git a/packages/types/package.json b/packages/types/package.json index 0f5cf6187b..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": { @@ -30,7 +38,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "globals": "16.3.0", "tsup": "8.5.1", "ajv": "8.20.0", diff --git a/packages/types/src/__tests__/kimi-code.test.ts b/packages/types/src/__tests__/kimi-code.test.ts new file mode 100644 index 0000000000..3d8b9ac549 --- /dev/null +++ b/packages/types/src/__tests__/kimi-code.test.ts @@ -0,0 +1,29 @@ +import { + SECRET_STATE_KEYS, + dynamicProviders, + kimiCodeDefaultModelId, + providerSettingsSchema, + providerSettingsSchemaDiscriminated, +} from "../index.js" + +describe("Kimi Code provider types", () => { + it("registers Kimi Code as a dynamic provider with a distinct secret", () => { + expect(dynamicProviders).toContain("kimi-code") + expect(SECRET_STATE_KEYS).toContain("kimiCodeApiKey") + expect(SECRET_STATE_KEYS).toContain("moonshotApiKey") + }) + + it("parses OAuth and API-key settings independently from Moonshot", () => { + expect( + providerSettingsSchemaDiscriminated.parse({ + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "kimi-key", + apiModelId: kimiCodeDefaultModelId, + }), + ).toMatchObject({ kimiCodeApiKey: "kimi-key" }) + expect(providerSettingsSchema.parse({ apiProvider: "kimi-code", kimiCodeAuthMethod: "oauth" })).toMatchObject({ + kimiCodeAuthMethod: "oauth", + }) + }) +}) diff --git a/packages/types/src/__tests__/lite-llm.test.ts b/packages/types/src/__tests__/lite-llm.test.ts new file mode 100644 index 0000000000..74436c1848 --- /dev/null +++ b/packages/types/src/__tests__/lite-llm.test.ts @@ -0,0 +1,48 @@ +import { isLiteLLMPreserveReasoningModel, LITELLM_PRESERVE_REASONING_MODEL_IDS } from "../providers/lite-llm.js" + +describe("LiteLLM preserveReasoning model detection", () => { + it("matches every explicitly listed model id", () => { + for (const modelId of LITELLM_PRESERVE_REASONING_MODEL_IDS) { + expect(isLiteLLMPreserveReasoningModel(modelId)).toBe(true) + } + }) + + it("does not contain duplicate model ids", () => { + expect(new Set(LITELLM_PRESERVE_REASONING_MODEL_IDS).size).toBe(LITELLM_PRESERVE_REASONING_MODEL_IDS.length) + }) + + it("matches provider-prefixed routed model names by their final segment", () => { + expect(isLiteLLMPreserveReasoningModel("kimi-k3")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("deepseek/deepseek-reasoner")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("bedrock/moonshot.kimi-k2-thinking")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("fireworks_ai/accounts/fireworks/models/kimi-k2p7-code")).toBe(true) + }) + + it("matches case-insensitively", () => { + expect(isLiteLLMPreserveReasoningModel("MiniMax-M2.7-Highspeed")).toBe(true) + expect(isLiteLLMPreserveReasoningModel("GLM-5.2")).toBe(true) + }) + + it("does not match model ids that merely contain a known family as a substring", () => { + expect(isLiteLLMPreserveReasoningModel("deepseek-v4-mini")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("mimo-v2.6")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("kimi-k2.6")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("kimi-k2.7-code")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("minimax-m4")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("minimax-m1")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("glm-4.7-flash")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("glm-4.7-flashx")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("glm-5-flash")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("glm-4.8")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("qwen3.5-plus")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("qwen3.7-mini")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("qwen3.6-max")).toBe(false) + }) + + it("does not match unrelated model names", () => { + expect(isLiteLLMPreserveReasoningModel("gpt-4")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("claude-3-opus")).toBe(false) + expect(isLiteLLMPreserveReasoningModel("")).toBe(false) + expect(isLiteLLMPreserveReasoningModel(undefined)).toBe(false) + }) +}) diff --git a/packages/types/src/__tests__/moonshot.test.ts b/packages/types/src/__tests__/moonshot.test.ts new file mode 100644 index 0000000000..61cafd3167 --- /dev/null +++ b/packages/types/src/__tests__/moonshot.test.ts @@ -0,0 +1,72 @@ +import type { ModelInfo } from "../model.js" +import { MOONSHOT_DEFAULT_TEMPERATURE, moonshotDefaultModelId, moonshotModels } from "../providers/moonshot.js" + +const modelEntries: [string, ModelInfo][] = Object.entries(moonshotModels) +const modelInfos: ModelInfo[] = Object.values(moonshotModels) + +describe("moonshot registry", () => { + describe("moonshotModels registry invariants", () => { + it("every entry has a positive maxTokens and contextWindow", () => { + for (const [id, info] of modelEntries) { + expect(info.maxTokens).toBeGreaterThan(0) + expect(info.contextWindow).toBeGreaterThan(0) + // Sanity: max output must not exceed the context window. + expect(info.maxTokens).toBeLessThanOrEqual(info.contextWindow) + void id + } + }) + + it("every entry declares supportsImages and supportsPromptCache", () => { + for (const info of modelInfos) { + expect(typeof info.supportsImages).toBe("boolean") + expect(typeof info.supportsPromptCache).toBe("boolean") + } + }) + + it("models with an array supportsReasoningEffort expose a non-empty allow-list", () => { + for (const info of modelInfos) { + if (Array.isArray(info.supportsReasoningEffort)) { + expect(info.supportsReasoningEffort.length).toBeGreaterThan(0) + } + } + }) + + it("every entry declares a reasoningEffort that is covered by its allow-list", () => { + for (const info of modelInfos) { + if (Array.isArray(info.supportsReasoningEffort) && info.reasoningEffort !== undefined) { + expect(info.supportsReasoningEffort).toContain(info.reasoningEffort) + } + } + }) + }) + + describe("kimi-k3", () => { + it("exposes always-on reasoning with effort allow-list and reasoning preservation", () => { + const info = moonshotModels["kimi-k3"] + expect(info).toBeDefined() + expect(info.maxTokens).toBe(131_072) + expect(info.contextWindow).toBe(1_048_576) + expect(info.supportsImages).toBe(true) + expect(info.supportsPromptCache).toBe(true) + expect(info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(info.reasoningEffort).toBe("max") + expect(info.preserveReasoning).toBe(true) + expect(info.defaultTemperature).toBe(1.0) + expect(info.inputPrice).toBe(3.0) + expect(info.outputPrice).toBe(15.0) + expect(info.cacheWritesPrice).toBe(0) + expect(info.cacheReadsPrice).toBe(0.3) + }) + }) + + describe("defaults", () => { + it("the default model id is a curated registry entry", () => { + expect(moonshotDefaultModelId).toBe("kimi-k2-0905-preview") + expect(moonshotModels[moonshotDefaultModelId]).toBeDefined() + }) + + it("exposes a deterministic default temperature", () => { + expect(MOONSHOT_DEFAULT_TEMPERATURE).toBe(0.6) + }) + }) +}) diff --git a/packages/types/src/__tests__/opencode-go.test.ts b/packages/types/src/__tests__/opencode-go.test.ts index 617c88675c..8079fe1bcb 100644 --- a/packages/types/src/__tests__/opencode-go.test.ts +++ b/packages/types/src/__tests__/opencode-go.test.ts @@ -21,6 +21,7 @@ describe("opencode-go registry", () => { "glm-5", "glm-5.1", "glm-5.2", + "kimi-k3", "kimi-k2.5", "kimi-k2.6", "mimo-v2.5", @@ -60,6 +61,23 @@ describe("opencode-go registry", () => { it("returns undefined for an unknown model ID", () => { expect(getOpencodeGoModelInfo("not-a-real-go-model")).toBeUndefined() }) + + it("kimi-k3 exposes always-on reasoning with effort allow-list and reasoning preservation", () => { + const info = getOpencodeGoModelInfo("kimi-k3") + expect(info).toBeDefined() + expect(info?.maxTokens).toBe(131_072) + expect(info?.contextWindow).toBe(1_048_576) + expect(info?.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(info?.reasoningEffort).toBe("max") + expect(info?.preserveReasoning).toBe(true) + expect(info?.defaultTemperature).toBe(1.0) + expect(info?.supportsPromptCache).toBe(true) + expect(info?.supportsMaxTokens).toBe(true) + expect(info?.supportsImages).toBe(false) + expect(info?.inputPrice).toBe(3.0) + expect(info?.outputPrice).toBe(15.0) + expect(info?.cacheReadsPrice).toBe(0.3) + }) }) describe("OPENCODE_GO_ANTHROPIC_FORMAT_MODELS", () => { diff --git a/packages/types/src/__tests__/provider-default-model.test.ts b/packages/types/src/__tests__/provider-default-model.test.ts new file mode 100644 index 0000000000..33ba019253 --- /dev/null +++ b/packages/types/src/__tests__/provider-default-model.test.ts @@ -0,0 +1,63 @@ +import type { ProviderName } from "../provider-settings.js" + +vi.mock("../provider-identifiers.js", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + providerIdentifiers: { + ...actual.providerIdentifiers, + openrouter: "canonical-openrouter-test-value", + }, + } +}) + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + anthropicDefaultModelId, + getProviderDefaultModelId, + internationalZAiDefaultModelId, + kimiCodeDefaultModelId, + mainlandZAiDefaultModelId, + openRouterDefaultModelId, + vscodeLlmDefaultModelId, + zooGatewayDefaultModelId, +} from "../providers/index.js" + +describe("getProviderDefaultModelId", () => { + it("selects a static default through the canonical provider identifier", () => { + expect(getProviderDefaultModelId(providerIdentifiers.openrouter as ProviderName)).toBe(openRouterDefaultModelId) + }) + + it("triangulates static selection with another provider category", () => { + expect(getProviderDefaultModelId(providerIdentifiers.vscodeLm)).toBe(vscodeLlmDefaultModelId) + }) + + it.each([ + [providerIdentifiers.kimiCode, kimiCodeDefaultModelId], + [providerIdentifiers.zooGateway, zooGatewayDefaultModelId], + ])("preserves the %s default added on main", (provider, expectedModelId) => { + expect(getProviderDefaultModelId(provider)).toBe(expectedModelId) + }) + + it("preserves region-dependent defaults", () => { + // These defaults currently share the same model ID, so the assertions document + // both branches but cannot detect swapped ternary arms until the IDs diverge. + expect(getProviderDefaultModelId(providerIdentifiers.zai, { isChina: true })).toBe(mainlandZAiDefaultModelId) + expect(getProviderDefaultModelId(providerIdentifiers.zai)).toBe(internationalZAiDefaultModelId) + }) + + it.each([providerIdentifiers.openai, providerIdentifiers.ollama, providerIdentifiers.lmstudio])( + "returns an empty default for custom or locally selected models from %s", + (provider) => { + expect(getProviderDefaultModelId(provider)).toBe("") + }, + ) + + it.each([providerIdentifiers.anthropic, providerIdentifiers.geminiCli, providerIdentifiers.fakeAi])( + "preserves the Anthropic fallback for %s", + (provider) => { + expect(getProviderDefaultModelId(provider)).toBe(anthropicDefaultModelId) + }, + ) +}) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts new file mode 100644 index 0000000000..b3640a8f5d --- /dev/null +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -0,0 +1,204 @@ +import { + customProviders, + dynamicProviders, + fauxProviders, + internalProviders, + isCustomProvider, + isDynamicProvider, + isFauxProvider, + isInternalProvider, + isLocalProvider, + isProviderName, + isRetiredProvider, + localProviders, + providerIdentifiers, + providerNames, + providerNamesSchema, + providerNamesWithRetiredSchema, + retiredProviderIdentifiers, + retiredProviderNames, + retiredProviderNamesSchema, +} from "../index.js" + +const expectedProviderIdentifiers = [ + "openrouter", + "vercel-ai-gateway", + "zoo-gateway", + "litellm", + "requesty", + "unbound", + "poe", + "deepseek", + "opencode-go", + "kenari", + "ollama", + "lmstudio", + "vscode-lm", + "openai", + "fake-ai", + "anthropic", + "bedrock", + "baseten", + "fireworks", + "friendli", + "gemini", + "gemini-cli", + "mistral", + "moonshot", + "kimi-code", + "minimax", + "mimo", + "openai-codex", + "openai-native", + "qwen-code", + "sambanova", + "vertex", + "xai", + "zai", +] + +const expectedRetiredProviderIdentifiers = [ + "cerebras", + "chutes", + "deepinfra", + "doubao", + "featherless", + "groq", + "huggingface", + "io-intelligence", + "roo", +] + +describe("provider identifiers", () => { + it("preserves active provider serialized values", () => { + const identifiers = Object.values(providerIdentifiers) + + expect(identifiers).toEqual(expectedProviderIdentifiers) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + it("preserves retired provider serialized values", () => { + const identifiers = Object.values(retiredProviderIdentifiers) + + expect(identifiers).toEqual(expectedRetiredProviderIdentifiers) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + it("preserves provider-settings compatibility exports", () => { + const identifiers = Object.values(providerIdentifiers) + const retiredIdentifiers = Object.values(retiredProviderIdentifiers) + + expect(providerNames).toEqual(identifiers) + expect(retiredProviderNames).toEqual(retiredIdentifiers) + }) + + it("derives provider category collections from canonical identifiers", () => { + expect(dynamicProviders).toEqual([ + providerIdentifiers.openrouter, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, + providerIdentifiers.litellm, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.poe, + providerIdentifiers.deepseek, + providerIdentifiers.moonshot, + providerIdentifiers.opencodeGo, + providerIdentifiers.kenari, + providerIdentifiers.kimiCode, + ]) + expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) + expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) + expect(customProviders).toEqual([providerIdentifiers.openai]) + expect(fauxProviders).toEqual([providerIdentifiers.fakeAi]) + }) + + it("preserves provider category type guards", () => { + for (const identifier of dynamicProviders) { + expect(isDynamicProvider(identifier)).toBe(true) + } + + for (const identifier of localProviders) { + expect(isLocalProvider(identifier)).toBe(true) + } + + for (const identifier of internalProviders) { + expect(isInternalProvider(identifier)).toBe(true) + } + + for (const identifier of customProviders) { + expect(isCustomProvider(identifier)).toBe(true) + } + + for (const identifier of fauxProviders) { + expect(isFauxProvider(identifier)).toBe(true) + } + + expect(isDynamicProvider("unknown-provider")).toBe(false) + expect(isLocalProvider("unknown-provider")).toBe(false) + expect(isInternalProvider("unknown-provider")).toBe(false) + expect(isCustomProvider("unknown-provider")).toBe(false) + expect(isFauxProvider("unknown-provider")).toBe(false) + + const categoryRepresentatives = [ + providerIdentifiers.openrouter, + providerIdentifiers.ollama, + providerIdentifiers.vscodeLm, + providerIdentifiers.openai, + providerIdentifiers.fakeAi, + ] + const categoryGuards = [ + isDynamicProvider, + isLocalProvider, + isInternalProvider, + isCustomProvider, + isFauxProvider, + ] + + for (const [guardIndex, guard] of categoryGuards.entries()) { + for (const [identifierIndex, identifier] of categoryRepresentatives.entries()) { + expect(guard(identifier)).toBe(guardIndex === identifierIndex) + } + } + }) + + it("keeps active and retired providers separate", () => { + const activeIdentifiers = new Set(Object.values(providerIdentifiers)) + const retiredIdentifiers = Object.values(retiredProviderIdentifiers) + + expect(retiredIdentifiers.every((identifier) => !activeIdentifiers.has(identifier))).toBe(true) + }) + + it("derives runtime validation from the canonical registries", () => { + for (const identifier of expectedProviderIdentifiers) { + expect(providerNamesSchema.safeParse(identifier).success).toBe(true) + expect(providerNamesWithRetiredSchema.safeParse(identifier).success).toBe(true) + } + + for (const identifier of expectedRetiredProviderIdentifiers) { + expect(providerNamesSchema.safeParse(identifier).success).toBe(false) + expect(retiredProviderNamesSchema.safeParse(identifier).success).toBe(true) + expect(providerNamesWithRetiredSchema.safeParse(identifier).success).toBe(true) + } + + expect(providerNamesSchema.safeParse("unknown-provider").success).toBe(false) + expect(retiredProviderNamesSchema.safeParse("unknown-provider").success).toBe(false) + expect(providerNamesWithRetiredSchema.safeParse("unknown-provider").success).toBe(false) + }) + + it("preserves provider-settings type guards", () => { + for (const identifier of expectedProviderIdentifiers) { + expect(isProviderName(identifier)).toBe(true) + expect(isRetiredProvider(identifier)).toBe(false) + } + + for (const identifier of expectedRetiredProviderIdentifiers) { + expect(isProviderName(identifier)).toBe(false) + expect(isRetiredProvider(identifier)).toBe(true) + } + + expect(isProviderName("unknown-provider")).toBe(false) + expect(isProviderName(undefined)).toBe(false) + expect(isRetiredProvider("unknown-provider")).toBe(false) + }) +}) 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/api.ts b/packages/types/src/api.ts index 2dbaae7920..89e9c8bc2b 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -64,6 +64,14 @@ export interface RooCodeAPI extends EventEmitter { * Cancels the current task. */ cancelCurrentTask(): Promise + /** + * Severs the delegated parent-child link for an interrupted (cancelled, not running) + * subtask, so the parent stops waiting on it and returns to "active". No-op (returns + * false) unless the child is interrupted and its parent is still delegated to it. + * @param childTaskId The ID of the child (subtask) to abandon. + * @returns True if the link was severed, false if there was nothing to abandon. + */ + abandonSubtask(childTaskId: string): Promise /** * Sends a message to the current task. * @param message Optional message to send. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 76420bc020..88b5408fca 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -300,6 +300,7 @@ export const SECRET_STATE_KEYS = [ "openAiNativeApiKey", "deepSeekApiKey", "moonshotApiKey", + "kimiCodeApiKey", "mistralApiKey", "minimaxApiKey", "requestyApiKey", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eaa419b1af..82588ae537 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,6 +18,7 @@ export * from "./mcp.js" export * from "./message.js" export * from "./mode.js" export * from "./model.js" +export * from "./provider-identifiers.js" export * from "./provider-settings.js" export * from "./task.js" export * from "./todo.js" diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 49a98a3dc1..ebbfb5bf38 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -182,7 +182,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [ description: "Plan and design before implementation", groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "mcp"], customInstructions: - "1. Do some information gathering (using provided tools) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently\n\n **Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.\n\n4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.\n\n5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.\n\n6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes (\"\") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.\n\n7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.\n\n**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**\n\n**CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.**\n\nUnless told otherwise, if you want to save a plan file, put it in the /plans directory", + '1. Do some information gathering (using provided tools) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you\'ve gained more context about the user\'s request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently\n\n **Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.\n\n4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.\n\n5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.\n\n6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes ("") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.\n\n7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.\n\n**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**\n\n**CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.**\n\nUnless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans)', }, { slug: "code", diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 87ebfaf967..9fbf9e358b 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -54,13 +54,34 @@ 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 const OpenAiCodexServiceTier = { + Default: "default", + Priority: "priority", +} as const + +export const openAiCodexServiceTiers = [OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const +export const openAiCodexServiceTierSchema = z.enum(openAiCodexServiceTiers) +export type OpenAiCodexServiceTier = z.infer + /** * ModelParameter */ @@ -122,6 +143,7 @@ export const modelInfoSchema = z.object({ }) .optional(), description: z.string().optional(), + displayName: z.string().optional(), // Default effort value for models that support reasoning effort reasoningEffort: reasoningEffortExtendedSchema.optional(), minTokensPerCachePoint: z.number().optional(), diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts new file mode 100644 index 0000000000..f231bc1ab1 --- /dev/null +++ b/packages/types/src/provider-identifiers.ts @@ -0,0 +1,58 @@ +/** + * Canonical provider identifiers. + * + * Values in this registry are persisted in settings and must remain stable. + */ +export const providerIdentifiers = { + openrouter: "openrouter", + vercelAiGateway: "vercel-ai-gateway", + zooGateway: "zoo-gateway", + litellm: "litellm", + requesty: "requesty", + unbound: "unbound", + poe: "poe", + deepseek: "deepseek", + opencodeGo: "opencode-go", + kenari: "kenari", + ollama: "ollama", + lmstudio: "lmstudio", + vscodeLm: "vscode-lm", + openai: "openai", + fakeAi: "fake-ai", + anthropic: "anthropic", + bedrock: "bedrock", + baseten: "baseten", + fireworks: "fireworks", + friendli: "friendli", + gemini: "gemini", + geminiCli: "gemini-cli", + mistral: "mistral", + moonshot: "moonshot", + kimiCode: "kimi-code", + minimax: "minimax", + mimo: "mimo", + openaiCodex: "openai-codex", + openaiNative: "openai-native", + qwenCode: "qwen-code", + sambanova: "sambanova", + vertex: "vertex", + xai: "xai", + zai: "zai", +} as const + +export type ProviderIdentifier = (typeof providerIdentifiers)[keyof typeof providerIdentifiers] + +/** Provider identifiers retained only for compatibility with existing settings. */ +export const retiredProviderIdentifiers = { + cerebras: "cerebras", + chutes: "chutes", + deepinfra: "deepinfra", + doubao: "doubao", + featherless: "featherless", + groq: "groq", + huggingface: "huggingface", + ioIntelligence: "io-intelligence", + roo: "roo", +} as const + +export type RetiredProviderIdentifier = (typeof retiredProviderIdentifiers)[keyof typeof retiredProviderIdentifiers] diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index fb03531c33..e17cd5ddbc 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -1,7 +1,19 @@ import { z } from "zod" -import { modelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js" +import { + modelInfoSchema, + openAiCodexServiceTierSchema, + reasoningEffortSettingSchema, + verbosityLevelsSchema, + serviceTierSchema, +} from "./model.js" import { codebaseIndexProviderSchema } from "./codebase-index.js" +import { + providerIdentifiers, + retiredProviderIdentifiers, + type ProviderIdentifier, + type RetiredProviderIdentifier, +} from "./provider-identifiers.js" import { anthropicModels, basetenModels, @@ -23,6 +35,8 @@ import { minimaxModels, mimoModels, isOpencodeGoAnthropicFormatModel, + ANTHROPIC_API_PROTOCOL, + OPENAI_API_PROTOCOL, } from "./providers/index.js" /** @@ -30,6 +44,7 @@ import { */ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 +export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" /** * DynamicProvider @@ -38,16 +53,18 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 */ export const dynamicProviders = [ - "openrouter", - "vercel-ai-gateway", - "zoo-gateway", - "litellm", - "requesty", - "unbound", - "poe", - "deepseek", - "opencode-go", - "kenari", + providerIdentifiers.openrouter, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, + providerIdentifiers.litellm, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.poe, + providerIdentifiers.deepseek, + providerIdentifiers.moonshot, + providerIdentifiers.opencodeGo, + providerIdentifiers.kenari, + providerIdentifiers.kimiCode, ] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -61,7 +78,7 @@ export const isDynamicProvider = (key: string): key is DynamicProvider => * Local providers require localhost API calls in order to get the model list. */ -export const localProviders = ["ollama", "lmstudio"] as const +export const localProviders = [providerIdentifiers.ollama, providerIdentifiers.lmstudio] as const export type LocalProvider = (typeof localProviders)[number] @@ -74,7 +91,7 @@ export const isLocalProvider = (key: string): key is LocalProvider => localProvi * model list. */ -export const internalProviders = ["vscode-lm"] as const +export const internalProviders = [providerIdentifiers.vscodeLm] as const export type InternalProvider = (typeof internalProviders)[number] @@ -87,7 +104,7 @@ export const isInternalProvider = (key: string): key is InternalProvider => * Custom providers are completely configurable within Roo Code settings. */ -export const customProviders = ["openai"] as const +export const customProviders = [providerIdentifiers.openai] as const export type CustomProvider = (typeof customProviders)[number] @@ -100,7 +117,7 @@ export const isCustomProvider = (key: string): key is CustomProvider => customPr * model lists. */ -export const fauxProviders = ["fake-ai"] as const +export const fauxProviders = [providerIdentifiers.fakeAi] as const export type FauxProvider = (typeof fauxProviders)[number] @@ -110,32 +127,7 @@ export const isFauxProvider = (key: string): key is FauxProvider => fauxProvider * ProviderName */ -export const providerNames = [ - ...dynamicProviders, - ...localProviders, - ...internalProviders, - ...customProviders, - ...fauxProviders, - "anthropic", - "bedrock", - "baseten", - "deepseek", - "fireworks", - "friendli", - "gemini", - "gemini-cli", - "mistral", - "moonshot", - "minimax", - "mimo", - "openai-codex", - "openai-native", - "qwen-code", - "sambanova", - "vertex", - "xai", - "zai", -] as const +export const providerNames = Object.values(providerIdentifiers) as [ProviderIdentifier, ...ProviderIdentifier[]] export const providerNamesSchema = z.enum(providerNames) @@ -148,17 +140,10 @@ export const isProviderName = (key: unknown): key is ProviderName => * RetiredProviderName */ -export const retiredProviderNames = [ - "cerebras", - "chutes", - "deepinfra", - "doubao", - "featherless", - "groq", - "huggingface", - "io-intelligence", - "roo", -] as const +export const retiredProviderNames = Object.values(retiredProviderIdentifiers) as [ + RetiredProviderIdentifier, + ...RetiredProviderIdentifier[], +] export const retiredProviderNamesSchema = z.enum(retiredProviderNames) @@ -301,7 +286,8 @@ 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]: openAiCodexServiceTierSchema.optional(), }) const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ @@ -334,6 +320,14 @@ const moonshotSchema = apiModelIdProviderModelSchema.extend({ moonshotApiKey: z.string().optional(), }) +export const kimiCodeAuthMethodSchema = z.enum(["oauth", "api-key"]) +export type KimiCodeAuthMethod = z.infer + +const kimiCodeSchema = apiModelIdProviderModelSchema.extend({ + kimiCodeAuthMethod: kimiCodeAuthMethodSchema.optional(), + kimiCodeApiKey: z.string().optional(), +}) + const minimaxSchema = apiModelIdProviderModelSchema.extend({ minimaxBaseUrl: z .union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")]) @@ -450,6 +444,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })), poeSchema.merge(z.object({ apiProvider: z.literal("poe") })), moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })), + kimiCodeSchema.merge(z.object({ apiProvider: z.literal("kimi-code") })), minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), mimoSchema.merge(z.object({ apiProvider: z.literal("mimo") })), requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })), @@ -488,6 +483,7 @@ export const providerSettingsSchema = z.object({ ...deepSeekSchema.shape, ...poeSchema.shape, ...moonshotSchema.shape, + ...kimiCodeSchema.shape, ...minimaxSchema.shape, ...mimoSchema.shape, ...requestySchema.shape, @@ -569,6 +565,7 @@ export const modelIdKeysByProvider: Record = { "gemini-cli": "apiModelId", mistral: "apiModelId", moonshot: "apiModelId", + "kimi-code": "apiModelId", minimax: "apiModelId", mimo: "apiModelId", deepseek: "apiModelId", @@ -594,25 +591,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 @@ -622,11 +636,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 } /** @@ -677,6 +696,11 @@ export const MODELS_BY_PROVIDER: Record< label: "Moonshot", models: Object.keys(moonshotModels), }, + "kimi-code": { + id: "kimi-code", + label: "Kimi Code", + models: [], + }, minimax: { id: "minimax", label: "MiniMax", diff --git a/packages/types/src/providers/anthropic.ts b/packages/types/src/providers/anthropic.ts index f56bfb4513..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": { @@ -145,6 +146,24 @@ export const anthropicModels = { supportsReasoningBinary: true, supportsTemperature: false, }, + "claude-opus-5": { + maxTokens: 128_000, // Overridden to 8k if `enableReasoningEffort` is false. + contextWindow: 1_000_000, // 1M context window native (no beta header required) + supportsImages: true, + supportsPromptCache: true, + inputPrice: 5.0, // $5 per million input tokens + outputPrice: 25.0, // $25 per million output tokens + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + // Opus 5 uses the same adaptive-thinking / binary-toggle convention as + // Opus 4.7+ and Sonnet 5 on the direct Anthropic provider path. Manual + // extended thinking (budget_tokens) is removed and returns a 400, and + // setting sampling parameters (temperature/top_p/top_k) returns a 400. + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Opus 5 is Anthropic's most capable model for complex agentic coding and enterprise work.", + }, "claude-fable-5": { maxTokens: 128_000, // Overridden to 8k if `enableReasoningEffort` is false. contextWindow: 1_000_000, diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 70fbe16057..78fea48845 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -235,6 +235,23 @@ export const bedrockModels = { }, ], }, + "anthropic.claude-opus-5": { + maxTokens: 8192, + contextWindow: 1_000_000, // 1M context window native (no beta header required) + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + inputPrice: 5.0, // $5 per million input tokens + outputPrice: 25.0, // $25 per million output tokens + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + minTokensPerCachePoint: 1024, + maxCachePoints: 4, + cachableFields: ["system", "messages", "tools"], + description: "Claude Opus 5 is Anthropic's most capable model for complex agentic coding and enterprise work.", + }, "anthropic.claude-fable-5": { maxTokens: 8192, contextWindow: 1_000_000, @@ -625,6 +642,7 @@ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ // - Claude Opus 4.5 // - Claude Opus 4.6 // - Claude Opus 4.7 +// - Claude Opus 5 // - Claude Fable 5 (cross-region inference only — can only be used through an inference profile) export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", @@ -636,6 +654,7 @@ export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-opus-4-6-v1", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-8", + "anthropic.claude-opus-5", "anthropic.claude-fable-5", ] as const diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index 143ce12fcc..0e40c2be13 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -12,7 +12,7 @@ export const deepSeekModels = { "deepseek-v4-flash": { maxTokens: 384_000, contextWindow: 1_000_000, - supportsImages: false, + supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], preserveReasoning: true, @@ -26,7 +26,7 @@ export const deepSeekModels = { "deepseek-v4-pro": { maxTokens: 384_000, contextWindow: 1_000_000, - supportsImages: false, + supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], preserveReasoning: true, 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/gemini.ts b/packages/types/src/providers/gemini.ts index b26faa39bd..e180be0867 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -6,6 +6,19 @@ export type GeminiModelId = keyof typeof geminiModels export const geminiDefaultModelId: GeminiModelId = "gemini-3.1-pro-preview" export const geminiModels = { + "gemini-3.6-flash": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.5, + outputPrice: 7.5, + cacheReadsPrice: 0.15, + cacheWritesPrice: 1.0, + supportsReasoningBudget: false, + }, "gemini-3.5-flash": { maxTokens: 65_536, contextWindow: 1_048_576, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 77b0415898..5f7d1afda4 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -25,6 +25,7 @@ export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" export * from "./kenari.js" +export * from "./kimi-code.js" export * from "./zai.js" export * from "./minimax.js" export * from "./mimo.js" @@ -53,6 +54,7 @@ import { xaiDefaultModelId } from "./xai.js" import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js" import { opencodeGoDefaultModelId } from "./opencode-go.js" import { kenariDefaultModelId } from "./kenari.js" +import { kimiCodeDefaultModelId } from "./kimi-code.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" import { minimaxDefaultModelId } from "./minimax.js" import { mimoDefaultModelId } from "./mimo.js" @@ -60,6 +62,9 @@ import { zooGatewayDefaultModelId } from "./zoo-gateway.js" // Import the ProviderName type from provider-settings to avoid duplication import type { ProviderName } from "../provider-settings.js" +import { providerIdentifiers } from "../provider-identifiers.js" + +const NO_DEFAULT_MODEL_ID = "" /** * Get the default model ID for a given provider. @@ -71,69 +76,70 @@ export function getProviderDefaultModelId( options: { isChina?: boolean } = { isChina: false }, ): string { switch (provider) { - case "openrouter": + case providerIdentifiers.openrouter: return openRouterDefaultModelId - case "requesty": + case providerIdentifiers.requesty: return requestyDefaultModelId - case "litellm": + case providerIdentifiers.litellm: return litellmDefaultModelId - case "xai": + case providerIdentifiers.xai: return xaiDefaultModelId - case "baseten": + case providerIdentifiers.baseten: return basetenDefaultModelId - case "bedrock": + case providerIdentifiers.bedrock: return bedrockDefaultModelId - case "vertex": + case providerIdentifiers.vertex: return vertexDefaultModelId - case "gemini": + case providerIdentifiers.gemini: return geminiDefaultModelId - case "deepseek": + case providerIdentifiers.deepseek: return deepSeekDefaultModelId - case "moonshot": + case providerIdentifiers.moonshot: return moonshotDefaultModelId - case "minimax": + case providerIdentifiers.minimax: return minimaxDefaultModelId - case "mimo": + case providerIdentifiers.mimo: return mimoDefaultModelId - case "zai": + case providerIdentifiers.zai: return options?.isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId - case "openai-native": + case providerIdentifiers.openaiNative: + // TODO(#992): Replace this stale fallback with openAiNativeDefaultModelId. return "gpt-4o" // Based on openai-native patterns - case "openai-codex": + case providerIdentifiers.openaiCodex: return openAiCodexDefaultModelId - case "mistral": + case providerIdentifiers.mistral: return mistralDefaultModelId - case "openai": - return "" // OpenAI provider uses custom model configuration - case "ollama": - return "" // Ollama uses dynamic model selection - case "lmstudio": - return "" // LMStudio uses dynamic model selection - case "vscode-lm": + case providerIdentifiers.openai: + case providerIdentifiers.ollama: + case providerIdentifiers.lmstudio: + return NO_DEFAULT_MODEL_ID + case providerIdentifiers.vscodeLm: return vscodeLlmDefaultModelId - case "sambanova": + case providerIdentifiers.sambanova: return sambaNovaDefaultModelId - case "fireworks": + case providerIdentifiers.fireworks: return fireworksDefaultModelId - case "friendli": + case providerIdentifiers.friendli: return friendliDefaultModelId - case "qwen-code": + case providerIdentifiers.qwenCode: return qwenCodeDefaultModelId - case "poe": + case providerIdentifiers.poe: return poeDefaultModelId - case "unbound": + case providerIdentifiers.unbound: return unboundDefaultModelId - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return vercelAiGatewayDefaultModelId - case "opencode-go": + case providerIdentifiers.opencodeGo: return opencodeGoDefaultModelId - case "kenari": + case providerIdentifiers.kenari: return kenariDefaultModelId - case "zoo-gateway": + case providerIdentifiers.kimiCode: + return kimiCodeDefaultModelId + case providerIdentifiers.zooGateway: return zooGatewayDefaultModelId - case "anthropic": - case "gemini-cli": - case "fake-ai": + case providerIdentifiers.anthropic: + case providerIdentifiers.geminiCli: + case providerIdentifiers.fakeAi: default: return anthropicDefaultModelId } diff --git a/packages/types/src/providers/kimi-code.ts b/packages/types/src/providers/kimi-code.ts new file mode 100644 index 0000000000..c9a458ed1f --- /dev/null +++ b/packages/types/src/providers/kimi-code.ts @@ -0,0 +1,23 @@ +import type { ModelInfo } from "../model.js" + +export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1" +export const kimiCodeDefaultModelId = "kimi-for-coding" + +export const kimiCodeReasoningEfforts = ["low", "high", "max"] as const + +export const kimiCodeDefaultModelInfo: ModelInfo = { + contextWindow: 262_144, + maxTokens: 32_768, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: [...kimiCodeReasoningEfforts], + requiredReasoningEffort: true, + reasoningEffort: "max", + description: "Kimi Code's coding model for subscription and API-key access.", +} + +export const kimiCodeModels = { + [kimiCodeDefaultModelId]: kimiCodeDefaultModelInfo, +} as const satisfies Record + +export type KimiCodeModelId = keyof typeof kimiCodeModels diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts index 14a68cfc3c..2b72de9d51 100644 --- a/packages/types/src/providers/lite-llm.ts +++ b/packages/types/src/providers/lite-llm.ts @@ -13,3 +13,86 @@ export const litellmDefaultModelInfo: ModelInfo = { cacheWritesPrice: 3.75, cacheReadsPrice: 0.3, } + +/** + * LiteLLM is a gateway: it fronts arbitrary underlying models and its + * `/v1/model/info` response carries no reasoning-related capability flags + * (no `preserveReasoning` equivalent). The underlying model identity is only + * visible as text in the model alias (`model_name`) or the routed target + * (`litellm_params.model`, e.g. `deepseek/deepseek-reasoner`, + * `bedrock/moonshot.kimi-k2-thinking`, `fireworks_ai/.../kimi-k2p7-code`). + * + * Rather than matching model-family substrings with a regex (which can + * over-match unrelated aliases, e.g. a family fragment appearing inside a + * longer unrelated model id), this is an explicit list of the exact model + * ids that set `preserveReasoning: true` in their native provider config + * (see deepseek.ts, mimo.ts, moonshot.ts, bedrock.ts, fireworks.ts, zai.ts, + * minimax.ts, opencode-go.ts). The same behavior is inferred for a + * LiteLLM-routed alias of the same underlying model. Keep this list in sync + * with those registries. This is still best-effort: unrecognized aliases or + * renamed deployments will not match, and callers should treat it as a + * heuristic, not a source of truth. + */ +export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ + // deepseek.ts + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-reasoner", + + // mimo.ts, opencode-go.ts + "mimo-v2.5", + "mimo-v2.5-pro", + + // moonshot.ts, bedrock.ts, fireworks.ts + "kimi-k2-thinking", + "moonshot.kimi-k2-thinking", + "kimi-k2p7-code", + + // moonshot.ts, opencode-go.ts + "kimi-k3", + + // zai.ts + "glm-4.7", + "glm-5", + "glm-5.1", + "glm-5.2", + "glm-5-turbo", + + // bedrock.ts, minimax.ts, opencode-go.ts + "minimax.minimax-m2", + "minimax-m2", + "minimax-m2-stable", + "minimax-m2.1", + "minimax-m2.1-highspeed", + "minimax-m2.5", + "minimax-m2.5-highspeed", + "minimax-m2.7", + "minimax-m2.7-highspeed", + "minimax-m3", + + // opencode-go.ts + "qwen3.6-plus", + "qwen3.7-plus", + "qwen3.7-max", +] as const + +const LITELLM_PRESERVE_REASONING_MODEL_ID_SET = new Set(LITELLM_PRESERVE_REASONING_MODEL_IDS) + +/** + * Checks whether `modelName` (a LiteLLM alias or routed `litellm_params.model` + * value) identifies a model that requires `preserveReasoning: true`. + * Provider-prefixed routed names (e.g. `deepseek/deepseek-reasoner`, + * `fireworks_ai/accounts/fireworks/models/kimi-k2p7-code`) are matched by + * their final slash-delimited segment. + */ +export function isLiteLLMPreserveReasoningModel(modelName: string | undefined): boolean { + const normalized = modelName?.trim().toLowerCase() + + if (!normalized) { + return false + } + + const modelId = normalized.split("/").pop() + + return modelId !== undefined && LITELLM_PRESERVE_REASONING_MODEL_ID_SET.has(modelId) +} diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts index 2ac02cf9af..7fe22e4041 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -5,9 +5,24 @@ import type { ModelInfo } from "../model.js" // https://platform.minimax.io/docs/guides/pricing-paygo // https://platform.minimax.io/docs/guides/pricing-tokenplan export type MinimaxModelId = keyof typeof minimaxModels -export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.7" +export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M3" export const minimaxModels = { + "MiniMax-M3": { + maxTokens: 16_384, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], + preserveReasoning: true, + inputPrice: 0.3, + outputPrice: 1.2, + cacheWritesPrice: 0.375, + cacheReadsPrice: 0.06, + description: + "MiniMax M3, MiniMax's frontier coding model with native multimodal (image and video) input and a 1M token context window, built for agent reasoning, tool calling, and long-context tasks. Prices reflect the permanent 50% discount; input, output, and cache read prices double for requests with over 512k input tokens. See pricing at https://platform.minimax.io/docs/guides/pricing-paygo. Note: When using TokenPlan, usage is billed per request, not per token.", + }, "MiniMax-M2.5": { maxTokens: 16_384, contextWindow: 204_800, diff --git a/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts index a825475644..8e041d160a 100644 --- a/packages/types/src/providers/moonshot.ts +++ b/packages/types/src/providers/moonshot.ts @@ -6,6 +6,21 @@ export type MoonshotModelId = keyof typeof moonshotModels export const moonshotDefaultModelId: MoonshotModelId = "kimi-k2-0905-preview" export const moonshotModels = { + "kimi-k3": { + maxTokens: 131_072, // Default max_completion_tokens (configurable up to 1,048,576) + contextWindow: 1_048_576, // 1M tokens + supportsImages: true, // Native visual understanding (text, image, video) + supportsPromptCache: true, // Automatic context caching + supportsReasoningEffort: ["low", "high", "max"], // Always reasons; default "max" + reasoningEffort: "max", + preserveReasoning: true, + inputPrice: 3.0, // $3.00 per million tokens (cache miss) + outputPrice: 15.0, // $15.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache miss) + cacheReadsPrice: 0.3, // $0.30 per million tokens (cache hit) + defaultTemperature: 1.0, // temperature is fixed at 1.0 + description: `Kimi K3 is Kimi's most capable flagship model with 2.8 trillion parameters, native visual understanding, and a 1M-token context window, designed for long-horizon coding, knowledge work, and deep reasoning. Thinking is always enabled with configurable reasoning effort (low/high/max, default max).`, + }, "kimi-k2-0711-preview": { maxTokens: 32_000, contextWindow: 131_072, @@ -56,7 +71,7 @@ export const moonshotModels = { "kimi-k2.5": { maxTokens: 16_384, contextWindow: 262_144, - supportsImages: false, + supportsImages: true, // Supports text, image, and video input supportsPromptCache: true, inputPrice: 0.6, // $0.60 per million tokens (cache miss) outputPrice: 3.0, // $3.00 per million tokens @@ -64,7 +79,43 @@ export const moonshotModels = { supportsTemperature: true, defaultTemperature: 1.0, description: - "Kimi K2.5 is the latest generation of Moonshot AI's Kimi series, featuring improved reasoning capabilities and enhanced performance across diverse tasks.", + "Kimi K2.5 supports text, image, and video input, thinking and non-thinking modes, and dialogue and agent tasks. Context length 256k.", + }, + "kimi-k2.6": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 0.95, // $0.95 per million tokens (cache miss) + outputPrice: 4.0, // $4.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.16, // $0.16 per million tokens (cache hit) + description: + "Kimi K2.6 is Kimi's latest and most intelligent model with stronger long-term code writing capabilities, improved instruction compliance, and self-correction. Native multimodal architecture supporting text, image, and video input. Context length 256k.", + }, + "kimi-k2.7-code": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 0.95, // $0.95 per million tokens (cache miss) + outputPrice: 4.0, // $4.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.19, // $0.19 per million tokens (cache hit) + description: + "Kimi K2.7 Code is Kimi's most intelligent Coding model for higher success rates in long context programming tasks. Native multimodal architecture supporting text, image, and video input. Context length 256k.", + }, + "kimi-k2.7-code-highspeed": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 1.9, // $1.90 per million tokens (cache miss) + outputPrice: 8.0, // $8.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.38, // $0.38 per million tokens (cache hit) + description: + "Kimi K2.7 Code HighSpeed is the high-speed version of Kimi K2.7 Code with output speed of approximately 180 Tokens/s (up to 260 Tokens/s in short context). Same model architecture, faster output. Context length 256k.", }, } as const satisfies Record 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/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts index 5b1e0ada8e..bd60c4d349 100644 --- a/packages/types/src/providers/opencode-go.ts +++ b/packages/types/src/providers/opencode-go.ts @@ -104,6 +104,23 @@ export const opencodeGoModels: Record = { }, // --- Moonshot Kimi --- + "kimi-k3": { + maxTokens: 131_072, // Default max_completion_tokens (configurable up to 1,048,576) + contextWindow: 1_048_576, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningEffort: ["low", "high", "max"], // Always reasons; default "max" + reasoningEffort: "max", + preserveReasoning: true, + defaultTemperature: 1.0, + // Go pricing matches Moonshot direct ($3 in / $0.30 cache / $15 out per 1M tokens). + inputPrice: 3.0, + outputPrice: 15.0, + cacheReadsPrice: 0.3, + description: + "Kimi K3 is Moonshot AI's flagship model with 2.8 trillion parameters, a 1M context window, and always-on reasoning with configurable effort (low/high/max). Available via the Opencode Go plan.", + }, "kimi-k2.5": { maxTokens: 16_384, contextWindow: 262_144, diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index 9b904bd6a9..9f146a304b 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -44,6 +44,7 @@ export const OPEN_ROUTER_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", + "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-haiku-4.5", "google/gemini-2.5-flash-preview", @@ -76,6 +77,7 @@ export const OPEN_ROUTER_REASONING_BUDGET_MODELS = new Set([ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", + "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.5", diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts index a231dd51d3..f44df88656 100644 --- a/packages/types/src/providers/vercel-ai-gateway.ts +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -13,6 +13,7 @@ export const VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", + "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.6", @@ -58,6 +59,7 @@ export const VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS = new Set([ "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", + "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.5", diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index dc25191543..46ff835682 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -6,6 +6,19 @@ export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-sonnet-4-5@20250929" export const vertexModels = { + "gemini-3.6-flash": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.5, + outputPrice: 7.5, + cacheReadsPrice: 0.15, + cacheWritesPrice: 1.0, + supportsReasoningBudget: false, + }, "gemini-3.5-flash": { maxTokens: 65_536, contextWindow: 1_048_576, @@ -449,6 +462,20 @@ export const vertexModels = { }, ], }, + "claude-opus-5": { + maxTokens: 8192, + contextWindow: 1_000_000, // 1M context window native (no beta header required) + supportsImages: true, + supportsPromptCache: true, + inputPrice: 5.0, // $5 per million input tokens + outputPrice: 25.0, // $25 per million output tokens + cacheWritesPrice: 6.25, // $6.25 per million tokens + cacheReadsPrice: 0.5, // $0.50 per million tokens + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Opus 5 is Anthropic's most capable model for complex agentic coding and enterprise work.", + }, "claude-fable-5": { maxTokens: 8192, contextWindow: 1_000_000, diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..c35a5da538 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -389,6 +389,14 @@ export type ExtensionState = Pick< mdmCompliant?: boolean taskSyncEnabled: boolean openAiCodexIsAuthenticated?: boolean + kimiCodeIsAuthenticated?: boolean + kimiCodeOAuthState?: { + status: "idle" | "authorizing" | "polling" | "authenticated" | "error" + userCode?: string + verificationUri?: string + expiresAt?: number + error?: string + } zooCodeIsAuthenticated?: boolean zooCodeUserName?: string zooCodeUserEmail?: string @@ -460,6 +468,7 @@ export interface WebviewMessage { | "shareCurrentTask" | "showTaskWithId" | "deleteTaskWithId" + | "abandonSubtaskWithId" | "exportTaskWithId" | "importSettings" | "exportSettings" @@ -537,6 +546,8 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "openAiCodexSignIn" | "openAiCodexSignOut" + | "kimiCodeSignIn" + | "kimiCodeSignOut" | "zooCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" diff --git a/packages/vscode-shim/package.json b/packages/vscode-shim/package.json index c366384104..167ec333d1 100644 --- a/packages/vscode-shim/package.json +++ b/packages/vscode-shim/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "vitest": "4.1.9" }, "dependencies": {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aa321660e..7c3dd070ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,13 +24,13 @@ importers: devDependencies: '@changesets/cli': specifier: 2.31.0 - version: 2.31.0(@types/node@20.19.43) + version: 2.31.0(@types/node@22.20.1) '@roo-code/config-typescript': specifier: workspace:^ version: link:packages/config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@vscode/vsce': specifier: 3.9.2 version: 3.9.2 @@ -45,7 +45,7 @@ importers: version: 9.1.7 knip: specifier: 5.60.2 - version: 5.60.2(@types/node@20.19.43)(typescript@5.9.3) + version: 5.60.2(@types/node@22.20.1)(typescript@5.9.3) lint-staged: specifier: 16.4.0 version: 16.4.0 @@ -126,8 +126,8 @@ importers: specifier: ^11.18.0 version: 11.18.0(typescript@5.9.3) '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/react': specifier: 18.3.31 version: 18.3.31 @@ -148,7 +148,7 @@ importers: version: 4.22.4 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/vscode-e2e: devDependencies: @@ -168,8 +168,8 @@ importers: specifier: 10.0.10 version: 10.0.10 '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/vscode': specifier: 1.100.0 version: 1.100.0 @@ -208,11 +208,11 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cloud: dependencies: @@ -233,8 +233,8 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/vscode': specifier: 1.100.0 version: 1.100.0 @@ -246,7 +246,7 @@ importers: version: 16.3.0 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/config-eslint: devDependencies: @@ -262,9 +262,6 @@ importers: eslint-config-prettier: specifier: 10.1.8 version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-only-warn: - specifier: 1.2.1 - version: 1.2.1 eslint-plugin-react: specifier: 7.37.5 version: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -311,14 +308,14 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(vitest@4.1.9) vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/ipc: dependencies: @@ -336,8 +333,8 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/node-ipc': specifier: 9.2.3 version: 9.2.3 @@ -361,8 +358,8 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/vscode': specifier: 1.100.0 version: 1.100.0 @@ -371,7 +368,7 @@ importers: version: 4.1.9(vitest@4.1.9) vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/types: dependencies: @@ -389,8 +386,8 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 ajv: specifier: 8.20.0 version: 8.20.0 @@ -402,7 +399,7 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) zod-to-json-schema: specifier: 3.25.2 version: 3.25.2(zod@3.25.76) @@ -416,11 +413,11 @@ importers: specifier: workspace:^ version: link:../config-typescript '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) src: dependencies: @@ -466,6 +463,9 @@ importers: '@roo-code/types': specifier: workspace:^ version: link:../packages/types + '@smithy/node-http-handler': + specifier: ^4.0.0 + version: 4.9.3 ai-sdk-provider-poe: specifier: 2.0.18 version: 2.0.18(ai@6.0.218(zod@3.25.76))(zod@3.25.76) @@ -514,6 +514,12 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + http-proxy-agent: + specifier: ^7.0.0 + version: 7.0.2 + https-proxy-agent: + specifier: ^7.0.0 + version: 7.0.6 i18next: specifier: ^25.0.0 version: 25.2.1(typescript@5.9.3) @@ -588,7 +594,7 @@ importers: version: 12.0.0 shell-quote: specifier: ^1.8.2 - version: 1.8.4 + version: 1.9.0 simple-git: specifier: ^3.27.0 version: 3.36.0 @@ -645,8 +651,8 @@ importers: specifier: 4.0.9 version: 4.0.9 '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/node-cache': specifier: 4.2.5 version: 4.2.5 @@ -659,9 +665,6 @@ importers: '@types/semver-compare': specifier: 1.0.3 version: 1.0.3 - '@types/shell-quote': - specifier: 1.7.5 - version: 1.7.5 '@types/vscode': specifier: 1.100.0 version: 1.100.0 @@ -675,8 +678,8 @@ importers: specifier: 6.0.218 version: 6.0.218(zod@3.25.76) esbuild-wasm: - specifier: 0.25.12 - version: 0.25.12 + specifier: 0.28.1 + version: 0.28.1 execa: specifier: 9.6.1 version: 9.6.1 @@ -694,7 +697,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) webview-ui: dependencies: @@ -748,7 +751,7 @@ importers: version: link:../packages/types '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.2(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.2(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.68.0 version: 5.101.2(react@18.3.1) @@ -856,13 +859,13 @@ importers: version: 0.6.4 shell-quote: specifier: ^1.8.2 - version: 1.8.4 + version: 1.9.0 shiki: specifier: ^3.2.1 version: 3.4.1 source-map: specifier: ^0.7.4 - version: 0.7.4 + version: 0.7.6 stacktrace-js: specifier: ^2.0.2 version: 2.0.2 @@ -894,6 +897,12 @@ importers: specifier: 3.25.76 version: 3.25.76 devDependencies: + '@playwright/experimental-ct-react': + specifier: 1.60.0 + version: 1.60.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0) + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 '@roo-code/config-eslint': specifier: workspace:^ version: link:../packages/config-eslint @@ -919,17 +928,14 @@ importers: specifier: 0.16.7 version: 0.16.7 '@types/node': - specifier: 20.19.43 - version: 20.19.43 + specifier: 22.20.1 + version: 22.20.1 '@types/react': specifier: 18.3.31 version: 18.3.31 '@types/react-dom': specifier: 18.3.7 version: 18.3.7(@types/react@18.3.31) - '@types/shell-quote': - specifier: 1.7.5 - version: 1.7.5 '@types/stacktrace-js': specifier: 2.0.3 version: 2.0.3 @@ -938,7 +944,7 @@ importers: version: 1.57.5 '@vitejs/plugin-react': specifier: 5.2.0 - version: 5.2.0(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 5.2.0(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(vitest@4.1.9) @@ -951,12 +957,15 @@ importers: jsdom: specifier: 26.1.0 version: 26.1.0 + monocart-reporter: + specifier: ^2.9.20 + version: 2.12.2 vite: specifier: 8.1.0 - version: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -1966,6 +1975,20 @@ packages: cpu: [x64] os: [win32] + '@playwright/experimental-ct-core@1.60.0': + resolution: {integrity: sha512-cMYDe0KpfdfFTZR2ev6frlgIwksSnXCu1o5s/DxpiVPTJeKp8TSXo9dzKuRhUMDAxAQESF1uM4uPQ56PZ/a5QA==} + engines: {node: '>=18'} + + '@playwright/experimental-ct-react@1.60.0': + resolution: {integrity: sha512-PT9RbpLO1nRtIcG/odJKM0m0UjQjLKYOcjs52UpASb8fEWovInHGi10Tw6JXl/3aMgSayX2hUzhYd8+yRFxj4w==} + engines: {node: '>=18'} + hasBin: true + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2531,6 +2554,9 @@ packages: cpu: [x64] os: [win32] + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -3156,8 +3182,8 @@ packages: '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} - '@types/node@20.19.43': - resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3191,9 +3217,6 @@ packages: '@types/semver-compare@1.0.3': resolution: {integrity: sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==} - '@types/shell-quote@1.7.5': - resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} - '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -3284,6 +3307,12 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: 8.1.0 + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3469,6 +3498,10 @@ packages: '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3478,11 +3511,24 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4005,10 +4051,17 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-grid@2.2.4: + resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -4028,6 +4081,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -4308,6 +4365,9 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4350,6 +4410,13 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4358,6 +4425,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -4466,6 +4537,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + eight-colors@1.3.3: + resolution: {integrity: sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==} + electron-to-chromium@1.5.152: resolution: {integrity: sha512-xBOfg/EBaIlVsHipHl2VdTPJRSvErNUaqW8ejTq5OlOlIYx1wOllCHsAvAIrr55jD1IYEfdR86miUEt8H5IeJg==} @@ -4555,8 +4629,8 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - esbuild-wasm@0.25.12: - resolution: {integrity: sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==} + esbuild-wasm@0.28.1: + resolution: {integrity: sha512-p/GD4E8oYRjg3kjdKrnMb0s4PzXgJF42e0MF4H0+ACyK/kIlFRp3e0fzOleIG+wBBm6MM3XQrbpe7soEA+vJIA==} engines: {node: '>=18'} hasBin: true @@ -4590,9 +4664,6 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-only-warn@1.2.1: - resolution: {integrity: sha512-j37hwfaQDEOfkZ1Dpvu/HnWLavlzQxQxfbrU/9Jb4R9qzrE1eTYuRJyrxq7LzLRI8miG5FOV6veoUVhx7AI84w==} - eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -4854,6 +4925,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + foreground-child@4.0.3: + resolution: {integrity: sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==} + engines: {node: '>=16'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -4871,6 +4946,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -4893,6 +4972,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5139,6 +5223,14 @@ packages: htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5698,6 +5790,10 @@ packages: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keytar@7.9.0: resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} @@ -5719,6 +5815,16 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4' + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-static-resolver@1.0.6: + resolution: {integrity: sha512-ZX5RshSzH8nFn05/vUNQzqw32nEigsPa67AVUr6ZuQxuGdnCcTLcdgr4C81+YbJjpgqKHfacMBd7NmJIbj7fXw==} + + koa@3.2.1: + resolution: {integrity: sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==} + engines: {node: '>= 18'} + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -5965,6 +6071,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + lz-utils@2.1.1: + resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==} + macos-release@3.3.0: resolution: {integrity: sha512-tPJQ1HeyiU2vRruNGhZ+VleWuMQRro8iFtJxYgnS4NQe+EukKF6aGiIT+7flZhISAt2iaXBCfFGvAyif7/f8nQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6282,6 +6391,17 @@ packages: peerDependencies: tslib: ^2.0.1 + monocart-coverage-reports@2.12.12: + resolution: {integrity: sha512-d9FdUr2dn58Crweon0IE0zVi8r/i4vLvfvb2G7eL5vL5LfrxCB2X6F6qzuiwV6RioA4zbAI//7CYi6LjCvN3zA==} + hasBin: true + + monocart-locator@1.0.3: + resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==} + + monocart-reporter@2.12.2: + resolution: {integrity: sha512-LkQQVeZvpYht4AmR76lBZj21pUxRKtTUmISktSbYQyDw09egqNJ3GJiM5d3QvtfobpogY8SdmEXml8wF9Rkrfw==} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6316,6 +6436,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6673,6 +6797,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -6891,6 +7025,10 @@ packages: peerDependencies: react: ^19.2.0 + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -7274,8 +7412,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} shiki@3.4.1: @@ -7360,10 +7498,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -7420,6 +7554,10 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7771,6 +7909,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -8938,7 +9080,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.31.0(@types/node@20.19.43)': + '@changesets/cli@2.31.0(@types/node@22.20.1)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -8954,7 +9096,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.2(@types/node@20.19.43) + '@inquirer/external-editor': 1.0.2(@types/node@22.20.1) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -9056,7 +9198,7 @@ snapshots: '@copilotkit/aimock@1.35.0(vitest@4.1.9)': optionalDependencies: - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@csstools/color-helpers@5.0.2': {} @@ -9317,12 +9459,12 @@ snapshots: figures: 6.1.0 ink: 6.6.0(@types/react@18.3.31)(react@19.2.7) - '@inquirer/external-editor@1.0.2(@types/node@20.19.43)': + '@inquirer/external-editor@1.0.2(@types/node@22.20.1)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 20.19.43 + '@types/node': 22.20.1 '@isaacs/cliui@8.0.2': dependencies: @@ -9346,7 +9488,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.43 + '@types/node': 22.20.1 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -9650,6 +9792,49 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.21.3': optional: true + '@playwright/experimental-ct-core@1.60.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)': + dependencies: + playwright: 1.60.0 + playwright-core: 1.60.0 + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@playwright/experimental-ct-react@1.60.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0)': + dependencies: + '@playwright/experimental-ct-core': 1.60.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitejs/plugin-react': 4.7.0(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - vite + - yaml + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@posthog/core@1.38.0': @@ -10184,6 +10369,8 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.3': optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rolldown/pluginutils@1.0.1': {} @@ -10482,12 +10669,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@tanstack/query-core@5.101.2': {} @@ -10808,13 +10995,13 @@ snapshots: '@types/node-ipc@9.2.3': dependencies: - '@types/node': 20.19.43 + '@types/node': 22.20.1 '@types/node@12.20.55': {} '@types/node@14.18.63': {} - '@types/node@20.19.43': + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -10845,8 +11032,6 @@ snapshots: '@types/semver-compare@1.0.3': {} - '@types/shell-quote@1.7.5': {} - '@types/stack-utils@2.0.3': {} '@types/stacktrace-js@2.0.3': @@ -10964,7 +11149,19 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@5.2.0(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-react@5.2.0(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10972,7 +11169,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10988,7 +11185,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -10999,13 +11196,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -11034,7 +11231,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@4.1.9': dependencies: @@ -11193,6 +11390,11 @@ snapshots: '@xobotyi/scrollbar-width@1.9.5': {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -11202,8 +11404,18 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-loose@8.5.2: + dependencies: + acorn: 8.17.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + acorn@8.15.0: {} + acorn@8.17.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -11756,10 +11968,14 @@ snapshots: consola@3.4.2: {} + console-grid@2.2.4: {} + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 + content-disposition@1.0.1: {} + content-type@1.0.5: {} convert-source-map@2.0.0: {} @@ -11770,6 +11986,11 @@ snapshots: cookie@0.7.2: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -12090,6 +12311,8 @@ snapshots: mimic-response: 3.1.0 optional: true + deep-equal@1.0.1: {} + deep-extend@0.6.0: optional: true @@ -12126,10 +12349,16 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: {} + + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-libc@2.1.2: {} @@ -12229,6 +12458,8 @@ snapshots: ee-first@1.1.1: {} + eight-colors@1.3.3: {} + electron-to-chromium@1.5.152: {} emoji-regex@10.4.0: {} @@ -12374,7 +12605,7 @@ snapshots: es6-error@4.1.1: {} - esbuild-wasm@0.25.12: {} + esbuild-wasm@0.28.1: {} esbuild@0.28.1: optionalDependencies: @@ -12419,8 +12650,6 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-only-warn@1.2.1: {} - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@2.7.0)): dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -12774,6 +13003,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + foreground-child@4.0.3: + dependencies: + signal-exit: 4.1.0 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -12792,6 +13025,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} from@0.1.7: {} @@ -12816,6 +13051,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -13147,6 +13385,19 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -13553,7 +13804,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.43 + '@types/node': 22.20.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -13711,6 +13962,10 @@ snapshots: dependencies: commander: 8.3.0 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keytar@7.9.0: dependencies: node-addon-api: 4.3.0 @@ -13727,10 +13982,10 @@ snapshots: kind-of@6.0.3: {} - knip@5.60.2(@types/node@20.19.43)(typescript@5.9.3): + knip@5.60.2(@types/node@22.20.1)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 20.19.43 + '@types/node': 22.20.1 fast-glob: 3.3.3 formatly: 0.2.4 jiti: 2.7.0 @@ -13745,6 +14000,31 @@ snapshots: zod: 3.25.76 zod-validation-error: 3.5.4(zod@3.25.76) + koa-compose@4.1.0: {} + + koa-static-resolver@1.0.6: {} + + koa@3.2.1: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -13949,6 +14229,8 @@ snapshots: lz-string@1.5.0: {} + lz-utils@2.1.1: {} + macos-release@3.3.0: {} magic-string@0.30.21: @@ -14534,6 +14816,33 @@ snapshots: fs-extra: 7.0.1 tslib: 2.8.1 + monocart-coverage-reports@2.12.12: + dependencies: + acorn: 8.17.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 14.0.3 + console-grid: 2.2.4 + eight-colors: 1.3.3 + foreground-child: 4.0.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + lz-utils: 2.1.1 + monocart-locator: 1.0.3 + + monocart-locator@1.0.3: {} + + monocart-reporter@2.12.2: + dependencies: + console-grid: 2.2.4 + eight-colors: 1.3.3 + koa: 3.2.1 + koa-static-resolver: 1.0.6 + lz-utils: 2.1.1 + monocart-coverage-reports: 2.12.12 + monocart-locator: 1.0.3 + mri@1.2.0: {} mrmime@2.0.1: {} @@ -14568,6 +14877,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} nock@14.0.15: @@ -14957,6 +15268,14 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@2.0.0: {} pluralize@8.0.0: {} @@ -15078,7 +15397,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 20.19.43 + '@types/node': 22.20.1 long: 5.3.2 proxy-addr@2.0.7: @@ -15195,6 +15514,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-refresh@0.17.0: {} + react-refresh@0.18.0: {} react-remark@2.1.0(react@18.3.1): @@ -15723,7 +16044,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.9.0: {} shiki@3.4.1: dependencies: @@ -15826,8 +16147,6 @@ snapshots: source-map@0.6.1: {} - source-map@0.7.4: {} - source-map@0.7.6: {} space-separated-tokens@1.1.5: {} @@ -15889,6 +16208,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -16237,6 +16558,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) @@ -16575,7 +16898,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -16583,17 +16906,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 20.19.43 + '@types/node': 22.20.1 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -16610,11 +16933,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 20.19.43 + '@types/node': 22.20.1 '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) '@vitest/ui': 4.1.9(vitest@4.1.9) jsdom: 26.1.0 diff --git a/renovate.json b/renovate.json index 69ba659080..cffcfa1f0c 100644 --- a/renovate.json +++ b/renovate.json @@ -83,6 +83,11 @@ "matchUpdateTypes": ["patch"], "automerge": true }, + { + "description": "Keep @types/node pinned to the Node 22 LTS major.", + "matchPackageNames": ["@types/node"], + "allowedVersions": "22.x" + }, { "description": "Update extension-host VS Code types only with an intentional minimum-version review.", "matchPackageNames": ["@types/vscode"], diff --git a/src/__tests__/abandonSubtask.spec.ts b/src/__tests__/abandonSubtask.spec.ts new file mode 100644 index 0000000000..ded593c596 --- /dev/null +++ b/src/__tests__/abandonSubtask.spec.ts @@ -0,0 +1,327 @@ +// npx vitest run __tests__/abandonSubtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import type { HistoryItem } from "@roo-code/types" + +import { ClineProvider } from "../core/webview/ClineProvider" +import { makeProviderStub } from "./helpers/provider-stub" + +/** + * Minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters + * against an in-memory item map and resolves, simulating the happy-path atomic write. + */ +function makeTaskHistoryStoreStub(childItem: Record, parentItem: Record) { + const itemMap = new Map>([ + [childItem.id!, childItem], + [parentItem.id!, parentItem], + ]) + + const atomicUpdatePair = vi.fn( + async ( + firstId: string, + secondId: string, + firstUpdater: (h: HistoryItem) => HistoryItem, + secondUpdater: (h: HistoryItem) => HistoryItem, + ) => { + itemMap.set(firstId, firstUpdater(itemMap.get(firstId) as HistoryItem)) + itemMap.set(secondId, secondUpdater(itemMap.get(secondId) as HistoryItem)) + return [] + }, + ) + + return { + atomicUpdatePair, + get: vi.fn((id: string) => itemMap.get(id)), + } +} + +describe("ClineProvider.abandonSubtask()", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("severs the link: parent → active (awaitingChildId/delegatedToId cleared), child loses parentTaskId/rootTaskId", async () => { + const childHistoryItem = { + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "parent-1", + ts: Date.now(), + task: "Child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore, + isViewLaunched: true, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) + const [firstId, secondId] = taskHistoryStore.atomicUpdatePair.mock.calls[0] + expect(firstId).toBe("child-1") + expect(secondId).toBe("parent-1") + + const updatedChild = taskHistoryStore.get("child-1") + expect(updatedChild).toEqual( + expect.objectContaining({ + id: "child-1", + status: "interrupted", + parentTaskId: undefined, + rootTaskId: undefined, + }), + ) + + const updatedParent = taskHistoryStore.get("parent-1") + expect(updatedParent).toEqual( + expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + ) + + // Guarded against a stale in-flight completion reattaching the child. + expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + + // Both updated items broadcast to the webview with the actual severed-link field values, + // not just matching IDs — a stale/pre-abandon payload would still match an id-only assertion. + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ + id: "child-1", + status: "interrupted", + parentTaskId: undefined, + rootTaskId: undefined, + }), + }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + }) + }) + + it("closes the live child instance before severing the link, so a later save cannot reattach it", async () => { + // An interrupted child is commonly still the live/open task (cancelTask rehydrates it + // onto the stack). If abandon doesn't close it first, Task#saveClineMessages() would + // rebuild parentTaskId/rootTaskId from the live task's readonly fields on its next save + // and silently reattach the child. removeClineFromStack() must run before atomicUpdatePair. + const childHistoryItem = { + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "parent-1", + ts: Date.now(), + task: "Child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), + removeClineFromStack, + taskHistoryStore, + isViewLaunched: false, + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(removeClineFromStack).toHaveBeenCalledWith() + // The live child must be closed before the persisted link is severed. + const closeCallOrder = removeClineFromStack.mock.invocationCallOrder[0] + const atomicCallOrder = taskHistoryStore.atomicUpdatePair.mock.invocationCallOrder[0] + expect(closeCallOrder).toBeLessThan(atomicCallOrder) + }) + + it("does not attempt to close the live instance when the child is not the current task", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => ({ taskId: "some-other-task" })), + removeClineFromStack, + taskHistoryStore, + isViewLaunched: false, + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(removeClineFromStack).not.toHaveBeenCalled() + }) + + it("returns false and does not modify state when the child is not interrupted (e.g. still active)", async () => { + const childHistoryItem = { id: "child-1", status: "active", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false when the child completes between the initial check and lock acquisition (TOCTOU)", async () => { + // The initial status check reads the child as interrupted, but by the time the + // per-parent lock is acquired, a concurrent resume-and-complete has already + // transitioned it. The in-lock re-check must catch this and bail out. + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore: any = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + // Simulate the concurrent completion landing right before the in-lock re-check runs. + taskHistoryStore.get = vi.fn((id: string) => + id === "child-1" ? { ...childHistoryItem, status: "completed" as const } : parentHistoryItem, + ) + + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false and does not modify state when the child has no parentTaskId", async () => { + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: { id: "standalone-1", status: "active" } }) + const provider = makeProviderStub({ getTaskWithId } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "standalone-1") + + expect(result).toBe(false) + }) + + it("returns false and does not touch history when parent is no longer delegated to this child", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { id: "parent-1", status: "active", awaitingChildId: undefined } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false when awaitingChildId points at a different child", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { id: "parent-1", status: "delegated", awaitingChildId: "child-OTHER" } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/api-subtask.spec.ts b/src/__tests__/api-subtask.spec.ts new file mode 100644 index 0000000000..2b5a1a86e9 --- /dev/null +++ b/src/__tests__/api-subtask.spec.ts @@ -0,0 +1,102 @@ +// npx vitest run __tests__/api-subtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { EventEmitter } from "events" + +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: [] }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + env: { language: "en" }, + Uri: { + file: vi.fn((p: string) => ({ fsPath: p })), + parse: vi.fn((s: string) => ({ toString: () => s })), + }, + commands: { registerCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }) }, +})) + +vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) })) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class { + listen() {} + on() {} + close() {} + }, +})) + +vi.mock("../services/command/commands", () => ({ getCommands: vi.fn().mockResolvedValue([]) })) + +import { API } from "../extension/api" + +function makeProviderMock() { + const emitter = new EventEmitter() + return { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + emit: emitter.emit.bind(emitter), + context: { + extensionPath: "/test", + globalStorageUri: { fsPath: "/test/storage" }, + subscriptions: [], + }, + cwd: "/test/cwd", + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + abandonSubtask: vi.fn().mockResolvedValue(true), + getCurrentTask: vi.fn().mockReturnValue(undefined), + viewLaunched: false, + cancelTask: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockRejectedValue(new Error("not found")), + taskHistoryStore: { get: vi.fn().mockReturnValue(undefined), getAll: vi.fn().mockReturnValue([]) }, + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockResolvedValue([]), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } +} + +describe("API.clearCurrentTask()", () => { + let provider: ReturnType + let api: API + + beforeEach(() => { + vi.clearAllMocks() + provider = makeProviderMock() + api = new API({} as any, provider as any) + }) + + it("calls evictCurrentTask then postStateToWebview on sidebarProvider", async () => { + await api.clearCurrentTask() + expect(provider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + // evict must come before postState + const evictOrder = provider.evictCurrentTask.mock.invocationCallOrder[0] + const postOrder = provider.postStateToWebview.mock.invocationCallOrder[0] + expect(evictOrder).toBeLessThan(postOrder) + }) +}) + +describe("API.abandonSubtask()", () => { + let provider: ReturnType + let api: API + + beforeEach(() => { + vi.clearAllMocks() + provider = makeProviderMock() + api = new API({} as any, provider as any) + }) + + it("delegates to sidebarProvider.abandonSubtask and returns its result", async () => { + provider.abandonSubtask.mockResolvedValue(true) + const result = await api.abandonSubtask("child-task-1") + expect(provider.abandonSubtask).toHaveBeenCalledWith("child-task-1") + expect(result).toBe(true) + }) + + it("returns false when sidebarProvider.abandonSubtask returns false", async () => { + provider.abandonSubtask.mockResolvedValue(false) + const result = await api.abandonSubtask("child-task-2") + expect(result).toBe(false) + }) +}) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index c9d77ad877..59dde33933 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -1,20 +1,55 @@ import { ClineProvider } from "../../core/webview/ClineProvider" +import { TaskRegistry } from "../../core/task/TaskRegistry" +import { type Task } from "../../core/task/Task" + +type ProviderStubFields = { + delegationTransitionLocks?: Map> + cancelledDelegationChildIds?: Set + log?: ReturnType + taskHistoryStore?: { get: (id: string) => unknown } + taskRegistry?: TaskRegistry + clineStack?: Task[] + tasks?: Task[] + runDelegationTransition?: unknown + removeClineFromStack?: unknown + evictCurrentTask?: unknown +} + +type PrivateProviderMethods = { + runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown + removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown + evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown +} /** * Augments a plain stub object with the instance fields and bound methods that * ClineProvider methods read from `this` (runDelegationTransition, * delegationTransitionLocks, cancelledDelegationChildIds, cancellingDelegationChildIds), - * so tests can call private methods via `(ClineProvider.prototype as any).method.call(stub, …)` + * so tests can call private ClineProvider methods against a plain object * without instantiating a real ClineProvider. + * + * Pass `tasks` (array of Task mocks) to pre-seed the registry in stack order. + * The legacy `clineStack` key is accepted and converted automatically. */ -export function makeProviderStub(stub: T): T { - const s = stub as any - const proto = ClineProvider.prototype as any +export function makeProviderStub(stub: T): ClineProvider { + const s = stub as T & ProviderStubFields + const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.delegationTransitionLocks ??= new Map() s.cancelledDelegationChildIds ??= new Set() - s.cancellingDelegationChildIds ??= new Set() s.log ??= vi.fn() s.taskHistoryStore ??= { get: () => undefined } - s.runDelegationTransition = proto.runDelegationTransition.bind(s) - return s + + // Convert legacy clineStack array into a TaskRegistry + if (!s.taskRegistry) { + const registry = new TaskRegistry() + const seed: Task[] = s.clineStack ?? s.tasks ?? [] + for (const t of seed) registry.push(t) + s.taskRegistry = registry + } + delete s.clineStack + + s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) + s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) + s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) + return s as unknown as ClineProvider } diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 0be2d28a3f..fc496d0c84 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -26,6 +26,15 @@ vi.mock("vscode", () => { return { window, workspace, env, Uri, commands, ExtensionMode, version } }) +// Mock TelemetryService (needed by attemptCompletionTool's emitTaskCompleted) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: vi.fn(), + }, + }, +})) + // Mock persistence BEFORE importing provider vi.mock("../core/task-persistence/taskMessages", () => ({ readTaskMessages: vi.fn().mockResolvedValue([]), @@ -159,7 +168,7 @@ describe("History resume delegation - parent metadata transitions", () => { // Verify child closed and parent reopened with updated metadata expect(removeClineFromStack).toHaveBeenCalledTimes(1) - expect(removeClineFromStack).toHaveBeenCalledWith({ skipDelegationRepair: true }) + expect(removeClineFromStack).toHaveBeenCalledWith() expect(createTaskWithHistoryItem).toHaveBeenCalledWith( expect.objectContaining({ status: "active", @@ -336,6 +345,12 @@ describe("History resume delegation - parent metadata transitions", () => { expect(injectedMsg.role).toBe("user") expect((injectedMsg.content[0] as any).type).toBe("tool_result") expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123") + + // Format contract with the e2e mock fixtures: the parent-resume fixtures in + // apps/vscode-e2e/src/fixtures/subtasks.ts match on this injected + // "completed.\n\nResult:" prefix (SUBTASK_RESULT_INJECTION). If this template + // changes, update the fixtures in the same PR or they silently never fire. + expect((injectedMsg.content[0] as any).content).toMatch(/^Subtask .+ completed\.\n\nResult:\n/) }) it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => { @@ -1135,4 +1150,140 @@ describe("History resume delegation - parent metadata transitions", () => { expect(capturedParentResult?.completedByChildId).toBe("c-handoff") }) }) + + describe("Issue #566 — manual stop/resume of a delegated subtask", () => { + it("reopens the parent after a subtask is cancelled mid-stream, resumed, and completes (interrupted → attempt_completion)", async () => { + // Step 1: simulate cancelTask()'s persisted transition — child "active" → "interrupted", + // parent stays "delegated" with awaitingChildId intact (ClineProvider.ts cancelTaskInternal). + const childItem: Record = { + id: "child-566", + status: "interrupted", + parentTaskId: "parent-566", + ts: 1, + task: "Child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + } + const parentItem: Record = { + id: "parent-566", + status: "delegated", + delegatedToId: "child-566", + awaitingChildId: "child-566", + childIds: ["child-566"], + ts: 0, + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + } + + // Step 2: user clicks "Resume" — the extension rehydrates the child from its persisted + // history item (createTaskWithHistoryItem passes historyItem.status through as + // initialStatus), so the resumed task instance still reports "interrupted". + let currentActiveId: string | undefined = "child-566" + const emitSpy = vi.fn() + const removeClineFromStack = vi.fn().mockImplementation(async () => { + currentActiveId = undefined + }) + const createTaskWithHistoryItem = vi.fn().mockImplementation(async (historyItem: any) => { + currentActiveId = historyItem.id + return { + taskId: historyItem.id, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + }) + const getTaskWithId = vi.fn(async (id: string) => { + const item = id === "child-566" ? childItem : id === "parent-566" ? parentItem : undefined + if (!item) throw new Error("Task not found") + return { historyItem: item } + }) + const taskHistoryStore = { + atomicUpdatePair: vi.fn( + async ( + firstId: string, + secondId: string, + firstUpdater: (h: any) => any, + secondUpdater: (h: any) => any, + ) => { + Object.assign(childItem, firstUpdater(childItem)) + Object.assign(parentItem, secondUpdater(parentItem)) + return [] + }, + ), + get: vi.fn((id: string) => + id === "child-566" ? childItem : id === "parent-566" ? parentItem : undefined, + ), + } + + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId, + emit: emitSpy, + getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore, + reopenParentFromDelegation: vi.fn(async (params: any) => { + // Intentional self-reference: the provider variable is initialized before this stub is invoked. + return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params) + }), + } as unknown as ClineProvider) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + // Step 3: the resumed subtask finishes its work and calls attempt_completion. + const { attemptCompletionTool } = await import("../core/tools/AttemptCompletionTool") + const resumedChildTask = { + taskId: "child-566", + parentTask: undefined, // live parent reference is gone after resume; only parentTaskId survives + parentTaskId: "parent-566", + historyItem: { parentTaskId: "parent-566" }, + providerRef: { deref: () => provider }, + say: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + getTokenUsage: vi.fn(() => ({})), + toolUsage: {}, + clineMessages: [], + userMessageContent: [], + consecutiveMistakeCount: 0, + emitFinalTokenUsageUpdate: vi.fn(), + } as unknown as import("../core/task/Task").Task + + const block = { + type: "tool_use", + name: "attempt_completion", + params: { result: "Child finished after resume" }, + nativeArgs: { result: "Child finished after resume" }, + partial: false, + } as any + + await attemptCompletionTool.handle(resumedChildTask, block, { + askApproval: vi.fn(), + handleError: vi.fn(async (_action: string, err: Error) => { + throw err + }), + pushToolResult: vi.fn(), + askFinishSubTaskApproval: vi.fn(async () => true), + toolDescription: () => "desc", + } as any) + + // The parent must regain control — this is the exact behavior issue #566 reported as broken. + expect(currentActiveId).toBe("parent-566") + expect(childItem.status).toBe("completed") + expect(parentItem.status).toBe("active") + expect(parentItem.awaitingChildId).toBeUndefined() + + const eventNames = emitSpy.mock.calls.map((c: any[]) => c[0]) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationCompleted) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationResumed) + }) + }) }) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index ddc6a3c4f4..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,15 +214,129 @@ 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 () => { + const oldChildId = "old-child" + const oldChild = { id: oldChildId, status: "interrupted" } as unknown as HistoryItem + const alreadyDelegatedParent: HistoryItem = { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + childIds: [oldChildId], + } as unknown as HistoryItem + + const taskHistoryStore = makeStoreStub({ + // store returns: parent (delegated), old child (interrupted) + get: vi.fn((id: string) => + id === "parent-1" ? alreadyDelegatedParent : id === oldChildId ? oldChild : undefined, + ), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + updater(alreadyDelegatedParent) + return [] + }), + }) + + 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(), run: () => Promise.resolve() }), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }) + + // The updater must sever the old link and apply the new delegation + const [, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] + const result = updater(alreadyDelegatedParent) + expect(result).toMatchObject({ + status: "delegated", + awaitingChildId: "child-2", + delegatedToId: "child-2", + }) + // Old child ID preserved in childIds (audit trail) + expect(result.childIds).toContain(oldChildId) + expect(result.childIds).toContain("child-2") + }) + + it("rejects with 'Cannot re-delegate' when the existing awaited child is still active", async () => { + const oldChildId = "old-child" + const activeChild = { id: oldChildId, status: "active" } as unknown as HistoryItem + const alreadyDelegatedParent: HistoryItem = { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } as unknown as HistoryItem + + 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) + return child + }) + + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => + id === "parent-1" ? alreadyDelegatedParent : id === oldChildId ? activeChild : undefined, + ), + // Real atomicReadAndUpdate behaviour: call the updater and propagate any throw + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + updater(alreadyDelegatedParent) + return [] + }), + }) + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: alreadyDelegatedParent }), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("Cannot re-delegate") + + // 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) @@ -226,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) @@ -236,6 +356,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) const provider = { + taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, removeClineFromStack, @@ -259,9 +380,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), ).rejects.toThrow(persistError) - expect(childStart).not.toHaveBeenCalled() - expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { skipDelegationRepair: true }) - expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { skipDelegationRepair: true }) + expect(childRun).not.toHaveBeenCalled() + expect(removeClineFromStack).toHaveBeenNthCalledWith(1) + expect(removeClineFromStack).toHaveBeenNthCalledWith(2) expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index 6013d8db99..e211a8f4ab 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -1,292 +1,411 @@ // npx vitest run __tests__/removeClineFromStack-delegation.spec.ts -import { describe, it, expect, vi } from "vitest" +import { describe, it, expect, vi, type MockedFunction } from "vitest" import { ClineProvider } from "../core/webview/ClineProvider" +import { TaskRegistry } from "../core/task/TaskRegistry" +import { type Task } from "../core/task/Task" import { makeProviderStub } from "./helpers/provider-stub" -describe("ClineProvider.removeClineFromStack() delegation awareness", () => { - /** - * Helper to build a minimal mock provider with a single task on the stack. - * The task's parentTaskId and taskId are configurable. - */ - function buildMockProvider(opts: { - childTaskId: string - parentTaskId?: string - parentHistoryItem?: Record - getTaskWithIdError?: Error - }) { - const childTask = { - taskId: opts.childTaskId, - instanceId: "inst-1", - parentTaskId: opts.parentTaskId, - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), +type MockTask = Pick & + Partial> & { + emit: ReturnType + abortTask: ReturnType + } + +type PrivateClineProviderMethods = { + removeClineFromStack: (this: ClineProvider) => ReturnType + markDelegatedChildInterrupted: ( + this: ClineProvider, + ...args: Parameters + ) => ReturnType + evictCurrentTask: (this: ClineProvider) => ReturnType +} + +const privateClineProvider = ClineProvider.prototype as unknown as PrivateClineProviderMethods + +// After the refactor: removeClineFromStack() is pure lifecycle — it removes the focused task, aborts, and +// cleans up listeners. It does NOT mutate delegation metadata. All delegated→active +// transitions are owned by reopenParentFromDelegation() (normal child completion) or +// markDelegatedChildInterrupted() (live eviction via navigation / new-task / clear). + +function buildMockProvider(opts: { + childTaskId: string + parentTaskId?: string + parentHistoryItem?: Record + childStatus?: string +}) { + const childTask = { + taskId: opts.childTaskId, + instanceId: "inst-1", + parentTaskId: opts.parentTaskId, + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === opts.parentTaskId && opts.parentHistoryItem) { + return { historyItem: { ...opts.parentHistoryItem } } } + throw new Error("Task not found") + }) - const updateTaskHistory = vi.fn().mockResolvedValue([]) - const getTaskWithId = opts.getTaskWithIdError - ? vi.fn().mockRejectedValue(opts.getTaskWithIdError) - : vi.fn().mockImplementation(async (id: string) => { - if (id === opts.parentTaskId && opts.parentHistoryItem) { - return { historyItem: { ...opts.parentHistoryItem } } - } - throw new Error("Task not found") - }) + const taskHistoryStoreData: Record = {} + if (opts.childStatus) { + taskHistoryStoreData[opts.childTaskId] = { status: opts.childStatus } + } + + const provider = makeProviderStub({ + clineStack: [childTask] as unknown as Task[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + taskHistoryStore: { get: (id: string) => taskHistoryStoreData[id] }, + }) + + return { provider, childTask, updateTaskHistory, getTaskWithId } +} + +describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation side effects", () => { + it("removes the focused task, aborts it, and clears listeners", async () => { + const { provider, childTask } = buildMockProvider({ childTaskId: "child-1" }) + expect(provider["taskRegistry"].length).toBe(1) + await privateClineProvider.removeClineFromStack.call(provider) + + expect(provider["taskRegistry"].length).toBe(0) + expect(childTask.abortTask).toHaveBeenCalledWith(true) + expect(childTask.emit).toHaveBeenCalledWith(expect.stringContaining("taskUnfocused")) + }) + + it("removes the focused task even when it is not the top stack entry", async () => { + const focusedTask = { + taskId: "focused-1", + instanceId: "focused-inst", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + const topTask = { + taskId: "top-1", + instanceId: "top-inst", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } const provider = makeProviderStub({ - clineStack: [childTask] as any[], + tasks: [focusedTask, topTask] as unknown as Task[], taskEventListeners: new Map(), log: vi.fn(), - getTaskWithId, - updateTaskHistory, + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), }) + provider["taskRegistry"].setCurrent("focused-1") - return { provider, childTask, updateTaskHistory, getTaskWithId } - } + await privateClineProvider.removeClineFromStack.call(provider) - it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => { + expect(provider["taskRegistry"].taskIds).toEqual(["top-1"]) + expect(provider["taskRegistry"].current).toBe(topTask) + expect(focusedTask.abortTask).toHaveBeenCalledWith(true) + expect(topTask.abortTask).not.toHaveBeenCalled() + }) + + it("does NOT mutate parent metadata when a delegated child is popped (repair removed)", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", parentHistoryItem: { id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, status: "delegated", awaitingChildId: "child-1", delegatedToId: "child-1", - childIds: ["child-1"], }, }) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - - // Stack should be empty after pop - expect(provider.clineStack).toHaveLength(0) - - // Parent lookup should have been called - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") - - // Parent metadata should be repaired - expect(updateTaskHistory).toHaveBeenCalledTimes(1) - const updatedParent = updateTaskHistory.mock.calls[0][0] - expect(updatedParent).toEqual( - expect.objectContaining({ - id: "parent-1", - status: "active", - awaitingChildId: undefined, - delegatedToId: undefined, - }), - ) + await privateClineProvider.removeClineFromStack.call(provider) - // Log the repair - expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata")) - }) - - it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => { - const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "standalone-1", - // No parentTaskId — this is a top-level task - }) - - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - - // Stack should be empty - expect(provider.clineStack).toHaveLength(0) - - // No parent lookup or update should happen + expect(provider["taskRegistry"].length).toBe(0) + // Navigation/disposal must never silently flip the parent to active expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT modify parent metadata when awaitingChildId does not match the popped child", async () => { + it("does NOT mutate parent metadata when the child is interrupted", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", parentHistoryItem: { id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, status: "delegated", - awaitingChildId: "child-OTHER", // different child - delegatedToId: "child-OTHER", - childIds: ["child-OTHER"], + awaitingChildId: "child-1", }, + childStatus: "interrupted", }) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await privateClineProvider.removeClineFromStack.call(provider) - // Parent was looked up but should NOT be updated - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(provider["taskRegistry"].length).toBe(0) + expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT modify parent metadata when parent status is not 'delegated'", async () => { + it("does NOT mutate parent metadata for a non-delegated (top-level) task", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - parentHistoryItem: { - id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "completed", // already completed - awaitingChildId: "child-1", - childIds: ["child-1"], - }, + childTaskId: "standalone-1", }) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await privateClineProvider.removeClineFromStack.call(provider) - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(provider["taskRegistry"].length).toBe(0) + expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("catches and logs errors during parent metadata repair without blocking the pop", async () => { - const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - getTaskWithIdError: new Error("Storage unavailable"), + it("handles empty stack gracefully", async () => { + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), }) - // Should NOT throw - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await expect(privateClineProvider.removeClineFromStack.call(provider)).resolves.not.toThrow() - // Stack should still be empty (pop was not blocked) - expect(provider.clineStack).toHaveLength(0) + expect(provider["getTaskWithId"]).not.toHaveBeenCalled() + expect(provider["updateTaskHistory"]).not.toHaveBeenCalled() + }) +}) - // The abort should still have been called - expect(childTask.abortTask).toHaveBeenCalledWith(true) +describe("ClineProvider.markDelegatedChildInterrupted() — live eviction path", () => { + it("marks an active delegated child interrupted and leaves parent delegated", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" - // Error should be logged as non-fatal - expect(provider.log).toHaveBeenCalledWith( - expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"), - ) + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === parentTaskId) { + return { + historyItem: { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + }, + } + } + if (id === childTaskId) { + return { + historyItem: { + id: childTaskId, + status: "active", + parentTaskId, + }, + } + } + throw new Error("Not found") + }) - // No update should have been attempted - expect(updateTaskHistory).not.toHaveBeenCalled() + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + postMessageToWebview, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) + + await privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) + + // Child must be marked interrupted + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: childTaskId, status: "interrupted" }), + ) + // Parent must NOT be touched at all — stays delegated + expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: parentTaskId })) + // Webview must receive correct field name: taskHistoryItem (not historyItem) + expect(postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ id: childTaskId, status: "interrupted" }), + }), + ) + expect(postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ id: parentTaskId, status: "delegated" }), + }), + ) }) - it("handles empty stack gracefully", async () => { - const provider = { - clineStack: [] as any[], + it("is a no-op when the child is already interrupted", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + + const provider = makeProviderStub({ + clineStack: [] as Task[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId: vi.fn(), - updateTaskHistory: vi.fn(), - } + updateTaskHistory, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "interrupted" } : undefined), + }, + }) - // Should not throw - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - expect(provider.clineStack).toHaveLength(0) - expect(provider.getTaskWithId).not.toHaveBeenCalled() - expect(provider.updateTaskHistory).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("skips delegation repair when skipDelegationRepair option is true", async () => { - const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - parentHistoryItem: { - id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - childIds: ["child-1"], + it("is a no-op when parent is no longer delegated to this child", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockResolvedValue({ + historyItem: { + id: parentTaskId, + status: "active", // already repaired by another path + awaitingChildId: undefined, }, }) - // Call with skipDelegationRepair: true (as delegateParentAndOpenChild would) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) - // Stack should be empty after pop - expect(provider.clineStack).toHaveLength(0) + await privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - // Parent lookup should NOT have been called — repair was skipped entirely - expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT reset grandparent during A→B→C nested delegation transition", async () => { - // Scenario: A delegated to B, B is now delegating to C. - // delegateParentAndOpenChild() pops B via removeClineFromStack({ skipDelegationRepair: true }). - // Grandparent A should remain "delegated" — its metadata must not be repaired. - const grandparentHistory = { - id: "task-A", - task: "Grandparent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "delegated", - awaitingChildId: "task-B", - delegatedToId: "task-B", - childIds: ["task-B"], - } + it("skips the update when cancelTask marks the child interrupted between outer check and lock (TOCTOU)", async () => { + // Outer store returns "active" (fast path passes), but inside the lock the store + // now returns "interrupted" (cancelTask beat us). The in-lock re-check must bail. + const childTaskId = "child-toctou" + const parentTaskId = "parent-1" - const taskB = { - taskId: "task-B", - instanceId: "inst-B", - parentTaskId: "task-A", - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), - } + const updateTaskHistory = vi.fn().mockResolvedValue([]) + let lockAcquired = false const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { - if (id === "task-A") { - return { historyItem: { ...grandparentHistory } } + if (id === parentTaskId) { + return { historyItem: { id: parentTaskId, status: "delegated", awaitingChildId: childTaskId } } } - throw new Error("Task not found") + // Child history fetch inside lock returns interrupted — cancelTask beat us + return { historyItem: { id: childTaskId, status: "interrupted", parentTaskId } } }) - const updateTaskHistory = vi.fn().mockResolvedValue([]) - const provider = { - clineStack: [taskB] as any[], + const realRunDelegation = ClineProvider.prototype["runDelegationTransition"].bind({ + delegationTransitionLocks: new Map(), + }) + const provider = makeProviderStub({ + clineStack: [] as Task[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId, updateTaskHistory, - } - - // Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair - await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + taskHistoryStore: { + // Outer check: "active" (pre-lock); in-lock check reads from taskHistoryStore too + // but the code falls back to getTaskWithId inside the lock when the store shows active. + get: vi.fn((id: string) => { + if (id === childTaskId) { + // After lock acquired, simulate cancelTask flipping to interrupted + return lockAcquired + ? { id: childTaskId, status: "interrupted" } + : { id: childTaskId, status: "active" } + } + return undefined + }), + }, + // Wrap the real runDelegationTransition to set lockAcquired before the lock fires + runDelegationTransition: async (parentId: string, fn: () => Promise) => { + lockAcquired = true + return realRunDelegation(parentId, fn) + }, + }) - // B was popped - expect(provider.clineStack).toHaveLength(0) + await privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - // Grandparent A should NOT have been looked up or modified - expect(getTaskWithId).not.toHaveBeenCalled() + // Since the in-lock store check returns "interrupted", the code skips updateTaskHistory expect(updateTaskHistory).not.toHaveBeenCalled() + }) - // Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B) - // The caller (delegateParentAndOpenChild) will update A to point to C separately. + it("logs and swallows errors from runDelegationTransition", async () => { + const childTaskId = "child-err" + const parentTaskId = "parent-1" + + const log = vi.fn() + const getTaskWithId = vi.fn().mockRejectedValue(new Error("store unavailable")) + + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + log, + getTaskWithId, + updateTaskHistory: vi.fn(), + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) + + await expect( + privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }), + ).resolves.not.toThrow() + + expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed for child")) }) +}) - it("does NOT repair parent when child has 'interrupted' status (cancel already persisted it)", async () => { - // cancelTask() writes child status: "interrupted" and leaves parent "delegated". - // When rehydrate then calls removeClineFromStack, parent must stay delegated. +describe("createTaskWithHistoryItem() navigation — does not mutate delegation state", () => { + it("navigating to a delegated parent while its interrupted child is current leaves parent delegated", async () => { + // This is the core regression: previously removeClineFromStack's repair fired here + // and flipped the parent to active, hiding the Abandon button. const childTaskId = "child-1" const parentTaskId = "parent-1" + const parentHistoryItem = { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + } + + const childHistoryItem = { + id: childTaskId, + status: "interrupted", + parentTaskId, + } + const childTask = { taskId: childTaskId, instanceId: "inst-child", @@ -297,44 +416,304 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { const updateTaskHistory = vi.fn().mockResolvedValue([]) const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { - if (id === parentTaskId) { - return { - historyItem: { - id: parentTaskId, - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "delegated", - awaitingChildId: childTaskId, - }, - } - } - throw new Error("Task not found") + if (id === parentTaskId) return { historyItem: { ...parentHistoryItem } } + if (id === childTaskId) return { historyItem: { ...childHistoryItem } } + throw new Error("Not found") }) + const markDelegatedChildInterrupted = vi.fn().mockResolvedValue(undefined) + const provider = makeProviderStub({ - clineStack: [childTask] as any[], + clineStack: [childTask] as unknown as Task[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId, updateTaskHistory, - // Seed the in-memory store with the interrupted child — mirrors what cancelTask - // writes before rehydrating, and is what removeClineFromStack now reads directly. - taskHistoryStore: { get: (id: string) => (id === childTaskId ? { status: "interrupted" } : undefined) }, + markDelegatedChildInterrupted, + taskHistoryStore: { + get: (id: string) => + id === childTaskId + ? { ...childHistoryItem } + : id === parentTaskId + ? { ...parentHistoryItem } + : undefined, + }, }) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + // Simulate the navigation logic from createTaskWithHistoryItem: + // when the target is a delegated parent and current task is its interrupted child, + // removeClineFromStack must NOT repair parent to active. + await privateClineProvider.removeClineFromStack.call(provider) + + // Parent must stay delegated — no write at all + expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: parentTaskId })) + }) + + it("navigating away from an active delegated child marks the child interrupted", async () => { + // Option A: live eviction of an active delegated child → child becomes interrupted, + // parent stays delegated, user can resume or abandon later. + const childTaskId = "child-active" + const parentTaskId = "parent-1" - // Stack is emptied - expect(provider.clineStack).toHaveLength(0) + const childHistoryItem = { + id: childTaskId, + status: "active", + parentTaskId, + } - // Parent must NOT be transitioned to active — it stays "delegated" - // so the interrupted child can resume and report back later + const parentHistoryItem = { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === parentTaskId) return { historyItem: { ...parentHistoryItem } } + if (id === childTaskId) return { historyItem: { ...childHistoryItem } } + throw new Error("Not found") + }) + + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + postMessageToWebview, + taskHistoryStore: { + get: (id: string) => + id === childTaskId + ? { ...childHistoryItem } + : id === parentTaskId + ? { ...parentHistoryItem } + : undefined, + }, + }) + + await privateClineProvider.markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) + + // Child becomes interrupted + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: childTaskId, status: "interrupted" }), + ) + // Parent stays delegated — awaitingChildId preserved expect(updateTaskHistory).not.toHaveBeenCalledWith( expect.objectContaining({ id: parentTaskId, status: "active" }), ) + expect(updateTaskHistory).not.toHaveBeenCalledWith( + expect.objectContaining({ id: parentTaskId, awaitingChildId: undefined }), + ) + }) +}) + +describe("ClineProvider.evictCurrentTask() — active delegated child path", () => { + it("calls markDelegatedChildInterrupted when current task is an active delegated child", async () => { + const childTaskId = "child-active" + const parentTaskId = "parent-1" + + const childTask = { + taskId: childTaskId, + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const childHistoryItem = { id: childTaskId, status: "active", parentTaskId } + + const markDelegatedChildInterrupted = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [childTask] as unknown as Task[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { get: vi.fn((id: string) => (id === childTaskId ? childHistoryItem : undefined)) }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await privateClineProvider.evictCurrentTask.call(provider) + + expect(provider["taskRegistry"].length).toBe(0) + expect(markDelegatedChildInterrupted).toHaveBeenCalledWith({ childTaskId, parentTaskId }) + }) + + it("does not call markDelegatedChildInterrupted when there is no current task", async () => { + const markDelegatedChildInterrupted = vi.fn() + + const provider = makeProviderStub({ + clineStack: [] as Task[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await privateClineProvider.evictCurrentTask.call(provider) + + expect(markDelegatedChildInterrupted).not.toHaveBeenCalled() + }) + + it("does not call markDelegatedChildInterrupted for a task with no parentTaskId", async () => { + const childTask = { + taskId: "standalone-1", + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const markDelegatedChildInterrupted = vi.fn() + + const provider = makeProviderStub({ + clineStack: [childTask] as unknown as Task[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { + get: vi.fn(() => ({ id: "standalone-1", status: "active", parentTaskId: undefined })), + }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await privateClineProvider.evictCurrentTask.call(provider) + + expect(markDelegatedChildInterrupted).not.toHaveBeenCalled() + }) + + it("propagates markDelegatedChildInterrupted errors (method swallows internally, not caller)", async () => { + // evictCurrentTask no longer has a caller-level .catch(); errors propagate + // from markDelegatedChildInterrupted directly. The real implementation swallows + // inside its own try/catch (after the guard reads); a mock that rejects bypasses + // that catch and exercises the propagation path. + const childTask = { + taskId: "child-err", + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const markDelegatedChildInterrupted = vi.fn().mockRejectedValue(new Error("lock contention")) + + const provider = makeProviderStub({ + clineStack: [childTask] as unknown as Task[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { + get: vi.fn(() => ({ id: "child-err", status: "active", parentTaskId: "parent-1" })), + }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await expect(privateClineProvider.evictCurrentTask.call(provider)).rejects.toThrow("lock contention") + }) +}) + +describe("onTaskCompleted callback — writes completed status before re-emitting", () => { + type CallbackHistoryItem = { id: string; status?: string; [key: string]: unknown } + + function buildCallbackProvider(taskHistoryStoreGet: (id: string) => CallbackHistoryItem | undefined) { + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const emit = vi.fn() + const log = vi.fn() + + // Wire up the real taskCreationCallback by calling the closure that ClineProvider + // sets on `this.taskCreationCallback` during construction. We extract it from the + // prototype's init code by calling the relevant portion directly. + const listeners: Record unknown)[]> = {} + const fakeTask = { + taskId: "task-1", + on: vi.fn((event: string, fn: (...args: unknown[]) => unknown) => { + listeners[event] = listeners[event] || [] + listeners[event].push(fn) + }), + emit: vi.fn((event: string, ...args: unknown[]) => { + listeners[event]?.forEach((fn) => fn(...args)) + }), + } + + const provider = makeProviderStub({ + taskHistoryStore: { get: taskHistoryStoreGet }, + updateTaskHistory, + emit, + log, + }) + + // Extract the real onTaskCompleted by simulating taskCreationCallback invocation. + // ClineProvider.prototype doesn't expose taskCreationCallback as a testable method, + // so we replicate the closure binding by calling the static block directly. + // The real callback is set in the constructor body; we replicate the relevant portion. + const onTaskCompleted = async (taskId: string) => { + try { + const existing = provider["taskHistoryStore"].get(taskId) + if (existing && existing.status !== "completed") { + await provider["updateTaskHistory"]({ ...existing, status: "completed" }) + } + } catch (err) { + provider["log"]( + `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + emit("TaskCompleted", taskId, {}, {}) + } + + return { onTaskCompleted, updateTaskHistory, emit, log } + } + + it("writes status:completed when existing record is not already completed", async () => { + const existingItem = { + id: "task-1", + status: "interrupted", + task: "T", + ts: 0, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).toHaveBeenCalledWith(expect.objectContaining({ id: "task-1", status: "completed" })) + }) + + it("skips the write when existing record is already completed", async () => { + const existingItem = { id: "task-1", status: "completed" } + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("skips the write when taskHistoryStore has no entry for the task", async () => { + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider(() => undefined) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("logs and swallows errors from updateTaskHistory", async () => { + const existingItem = { id: "task-1", status: "active" } + const { onTaskCompleted, updateTaskHistory, log } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + vi.mocked(updateTaskHistory).mockRejectedValue(new Error("disk full")) + + await expect(onTaskCompleted("task-1")).resolves.not.toThrow() + + expect(log).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) }) }) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index af5b429a80..9eeec8a960 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -1,25 +1,54 @@ // npx vitest run __tests__/single-open-invariant.spec.ts 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" +type PrivateClineProviderMethods = { + createTask: ( + this: unknown, + text?: string, + images?: string[], + parentTask?: Task, + options?: Parameters[3], + ) => ReturnType + createTaskWithHistoryItem: ( + this: unknown, + ...args: Parameters + ) => ReturnType + evictCurrentTask: (this: unknown) => ReturnType +} + +const privateClineProvider = ClineProvider.prototype as unknown as PrivateClineProviderMethods + // Mock Task class used by ClineProvider to avoid heavy startup vi.mock("../core/task/Task", () => { class TaskStub { public taskId: string public instanceId = "inst" - public parentTask?: any - public apiConfiguration: any - public rootTask?: any - constructor(opts: any) { + public parentTask?: unknown + public apiConfiguration: unknown + public rootTask?: unknown + constructor(opts: { + historyItem?: { id: string } + parentTask?: unknown + apiConfiguration?: unknown + onCreated?: (t: TaskStub) => void + }) { this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` this.parentTask = opts.parentTask this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" } opts.onCreated?.(this) } start() {} + run() { + return Promise.resolve() + } on() {} off() {} emit() {} @@ -38,10 +67,20 @@ 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 = { - // Simulate an existing task present in stack - clineStack: [{ taskId: "existing-1" }], + taskRegistry: registry, + taskScheduler: { schedule: schedulespy }, + getCurrentTask: vi.fn(() => existingTask), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return privateClineProvider.evictCurrentTask.bind(this) + }, setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, @@ -67,10 +106,11 @@ describe("Single-open-task invariant", () => { }, } as unknown as ClineProvider - await (ClineProvider.prototype as any).createTask.call(provider, "New task") + await privateClineProvider.createTask.call(provider, "New task") expect(removeClineFromStack).toHaveBeenCalledTimes(1) expect(addClineToStack).toHaveBeenCalledTimes(1) + expect(schedulespy).toHaveBeenCalledTimes(1) }) it("Subtask create: keeps existing task open when parentTask is provided", async () => { @@ -78,10 +118,13 @@ describe("Single-open-task invariant", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const addClineToStack = vi.fn().mockResolvedValue(undefined) - const parentTask = { taskId: "parent-1" } + const parentTask = { taskId: "parent-1", abort: false, abandoned: false } + const registry2 = new TaskRegistry() + registry2.push(parentTask as unknown as Task) const provider = { - clineStack: [parentTask], + taskRegistry: registry2, + taskScheduler: new TaskScheduler(), setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, @@ -107,7 +150,7 @@ describe("Single-open-task invariant", () => { }, } as unknown as ClineProvider - await (ClineProvider.prototype as any).createTask.call(provider, "Subtask", undefined, parentTask as any) + await privateClineProvider.createTask.call(provider, "Subtask", undefined, parentTask as unknown as Task) expect(removeClineFromStack).not.toHaveBeenCalled() expect(addClineToStack).toHaveBeenCalledTimes(1) @@ -117,9 +160,15 @@ 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 + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return privateClineProvider.evictCurrentTask.bind(this) + }, removeClineFromStack, addClineToStack, updateGlobalState, @@ -140,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: {}, @@ -162,24 +212,106 @@ describe("Single-open-task invariant", () => { workspace: "/tmp", } - const task = await (ClineProvider.prototype as any).createTaskWithHistoryItem.call(provider, historyItem) + const task = await privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) 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 () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" }) const provider = { - context: {} as any, + context: {} as unknown, + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return privateClineProvider.evictCurrentTask.bind(this) + }, removeClineFromStack, postStateToWebview: vi.fn(), postMessageToWebview: vi.fn(), createTask, getValues: vi.fn(() => ({})), providerSettingsManager: { saveConfig: vi.fn() }, - on: vi.fn((ev: any, cb: any) => { + on: vi.fn((ev: unknown, cb: unknown) => { if (ev === "taskCreated") { // no-op for this test } @@ -187,7 +319,7 @@ describe("Single-open-task invariant", () => { }), } as unknown as ClineProvider - const output = { appendLine: vi.fn() } as any + const output = { appendLine: vi.fn() } as unknown as OutputChannel const api = new API(output, provider, undefined, false) const taskId = await api.startNewTask({ 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 6e25f62eaf..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: [ @@ -348,4 +356,72 @@ describe("registerCommands handlers", () => { `[toggleAutoApprove] postMessageToWebview failed: ${boom}`, ) }) + + it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { + const evictCurrentTask = vi.fn().mockResolvedValue(undefined) + const refreshWorkspace = vi.fn().mockResolvedValue(undefined) + ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask + ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace + + await handlers["zoo-code.plusButtonClicked"]() + + expect(evictCurrentTask).toHaveBeenCalledTimes(1) + }) + + it("plusButtonClicked is a no-op when no visible provider", async () => { + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + // Should not throw even with no visible provider + 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 db50edcee1..692aabfd68 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -100,7 +100,7 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.removeClineFromStack() + await visibleProvider.evictCurrentTask() await visibleProvider.refreshWorkspace() await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) // Send focusInput action immediately after chatButtonClicked @@ -251,7 +251,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit ({ }, })) -import type { ProviderSettings } from "@roo-code/types" +// Handler constructors can require credentials or initialize SDK clients. Replace them +// with inert classes so these tests exercise only the factory's routing behavior. +vitest.mock("../providers", async () => { + const providers = await vitest.importActual>("../providers") + + return Object.fromEntries(Object.keys(providers).map((name) => [name, class {}])) +}) + +vitest.mock("../providers/native-ollama", () => ({ + NativeOllamaHandler: class {}, +})) + +import { + providerIdentifiers, + retiredProviderIdentifiers, + type ProviderName, + type ProviderNameWithRetired, +} from "@roo-code/types" import { buildApiHandler } from "../index" -import { KenariHandler } from "../providers/kenari" +import { + AnthropicHandler, + AnthropicVertexHandler, + AwsBedrockHandler, + BasetenHandler, + DeepSeekHandler, + FakeAIHandler, + FireworksHandler, + FriendliHandler, + GeminiHandler, + KenariHandler, + KimiCodeHandler, + LiteLLMHandler, + LmStudioHandler, + MiniMaxHandler, + MimoHandler, + MistralHandler, + MoonshotHandler, + OpenAiCodexHandler, + OpenAiHandler, + OpenAiNativeHandler, + OpencodeGoHandler, + OpenRouterHandler, + PoeHandler, + QwenCodeHandler, + RequestyHandler, + SambaNovaHandler, + UnboundHandler, + VercelAiGatewayHandler, + VertexHandler, + VsCodeLmHandler, + XAIHandler, + ZAiHandler, + ZooGatewayHandler, +} from "../providers" +import { NativeOllamaHandler } from "../providers/native-ollama" + +type HandlerConstructor = new (...args: never[]) => object + +const expectedHandlers = { + [providerIdentifiers.anthropic]: AnthropicHandler, + [providerIdentifiers.openrouter]: OpenRouterHandler, + [providerIdentifiers.bedrock]: AwsBedrockHandler, + [providerIdentifiers.openai]: OpenAiHandler, + [providerIdentifiers.ollama]: NativeOllamaHandler, + [providerIdentifiers.lmstudio]: LmStudioHandler, + [providerIdentifiers.gemini]: GeminiHandler, + // Gemini CLI currently relies on the factory's default Anthropic handler. + [providerIdentifiers.geminiCli]: AnthropicHandler, + [providerIdentifiers.openaiCodex]: OpenAiCodexHandler, + [providerIdentifiers.openaiNative]: OpenAiNativeHandler, + [providerIdentifiers.deepseek]: DeepSeekHandler, + [providerIdentifiers.qwenCode]: QwenCodeHandler, + [providerIdentifiers.moonshot]: MoonshotHandler, + [providerIdentifiers.kimiCode]: KimiCodeHandler, + [providerIdentifiers.vscodeLm]: VsCodeLmHandler, + [providerIdentifiers.mistral]: MistralHandler, + [providerIdentifiers.requesty]: RequestyHandler, + [providerIdentifiers.unbound]: UnboundHandler, + [providerIdentifiers.fakeAi]: FakeAIHandler, + [providerIdentifiers.xai]: XAIHandler, + [providerIdentifiers.litellm]: LiteLLMHandler, + [providerIdentifiers.sambanova]: SambaNovaHandler, + [providerIdentifiers.mimo]: MimoHandler, + [providerIdentifiers.zai]: ZAiHandler, + [providerIdentifiers.fireworks]: FireworksHandler, + [providerIdentifiers.friendli]: FriendliHandler, + [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, + [providerIdentifiers.opencodeGo]: OpencodeGoHandler, + [providerIdentifiers.kenari]: KenariHandler, + [providerIdentifiers.zooGateway]: ZooGatewayHandler, + [providerIdentifiers.minimax]: MiniMaxHandler, + [providerIdentifiers.baseten]: BasetenHandler, + [providerIdentifiers.poe]: PoeHandler, +} satisfies Record, HandlerConstructor> + +const expectedHandlerEntries = Object.entries(expectedHandlers) as Array< + [Exclude, HandlerConstructor] +> describe("buildApiHandler", () => { - it("returns a KenariHandler for the kenari provider", () => { - const configuration: ProviderSettings = { - apiProvider: "kenari", - kenariApiKey: "test-key", - kenariModelId: "glm-5-2", - } + it.each(expectedHandlerEntries)("returns the expected handler for %s", (apiProvider, Handler) => { + const handler = buildApiHandler({ apiProvider }) + + expect(handler).toBeInstanceOf(Handler) + }) + + it.each([ + ["an unspecified model", undefined, VertexHandler], + ["non-Claude models", "non-claude-test-model", VertexHandler], + ["Claude models", "claude-test-model", AnthropicVertexHandler], + ] as const)("returns the expected Vertex handler for %s", (_description, apiModelId, Handler) => { + const handler = buildApiHandler({ + apiProvider: providerIdentifiers.vertex, + apiModelId, + }) + + expect(handler).toBeInstanceOf(Handler) + }) + + it("preserves the dedicated removal error for the retired Roo provider", () => { + expect(() => + buildApiHandler({ + apiProvider: retiredProviderIdentifiers.roo, + }), + ).toThrow("Roo Code Router has been removed") + }) + + it("rejects other retired providers", () => { + expect(() => + buildApiHandler({ + apiProvider: retiredProviderIdentifiers.cerebras, + }), + ).toThrow("this provider is no longer supported") + }) - const handler = buildApiHandler(configuration) + it("falls back to Anthropic for an unsupported provider value", () => { + const handler = buildApiHandler({ + apiProvider: "unsupported-provider" as ProviderNameWithRetired, + }) - expect(handler).toBeInstanceOf(KenariHandler) + expect(handler).toBeInstanceOf(AnthropicHandler) }) }) diff --git a/src/api/index.ts b/src/api/index.ts index 48a070feb4..c9992c9c39 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" +import { + isRetiredProvider, + providerIdentifiers, + retiredProviderIdentifiers, + type ProviderSettings, + type ModelInfo, +} from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" import { ApiStream } from "./transform/stream" @@ -20,6 +26,7 @@ import { OpenAiNativeHandler, DeepSeekHandler, MoonshotHandler, + KimiCodeHandler, MistralHandler, VsCodeLmHandler, RequestyHandler, @@ -139,7 +146,7 @@ export interface ApiHandler { export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration - if (apiProvider === "roo") { + if (apiProvider === retiredProviderIdentifiers.roo) { throw new Error(getRouterRemovalMessage()) } @@ -150,71 +157,73 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { } switch (apiProvider) { - case "anthropic": + case providerIdentifiers.anthropic: return new AnthropicHandler(options) - case "openrouter": + case providerIdentifiers.openrouter: return new OpenRouterHandler(options) - case "bedrock": + case providerIdentifiers.bedrock: return new AwsBedrockHandler(options) - case "vertex": + case providerIdentifiers.vertex: return options.apiModelId?.startsWith("claude") ? new AnthropicVertexHandler(options) : new VertexHandler(options) - case "openai": + case providerIdentifiers.openai: return new OpenAiHandler(options) - case "ollama": + case providerIdentifiers.ollama: return new NativeOllamaHandler(options) - case "lmstudio": + case providerIdentifiers.lmstudio: return new LmStudioHandler(options) - case "gemini": + case providerIdentifiers.gemini: return new GeminiHandler(options) - case "openai-codex": + case providerIdentifiers.openaiCodex: return new OpenAiCodexHandler(options) - case "openai-native": + case providerIdentifiers.openaiNative: return new OpenAiNativeHandler(options) - case "deepseek": + case providerIdentifiers.deepseek: return new DeepSeekHandler(options) - case "qwen-code": + case providerIdentifiers.qwenCode: return new QwenCodeHandler(options) - case "moonshot": + case providerIdentifiers.moonshot: return new MoonshotHandler(options) - case "vscode-lm": + case providerIdentifiers.kimiCode: + return new KimiCodeHandler(options) + case providerIdentifiers.vscodeLm: return new VsCodeLmHandler(options) - case "mistral": + case providerIdentifiers.mistral: return new MistralHandler(options) - case "requesty": + case providerIdentifiers.requesty: return new RequestyHandler(options) - case "unbound": + case providerIdentifiers.unbound: return new UnboundHandler(options) - case "fake-ai": + case providerIdentifiers.fakeAi: return new FakeAIHandler(options) - case "xai": + case providerIdentifiers.xai: return new XAIHandler(options) - case "litellm": + case providerIdentifiers.litellm: return new LiteLLMHandler(options) - case "sambanova": + case providerIdentifiers.sambanova: return new SambaNovaHandler(options) - case "mimo": + case providerIdentifiers.mimo: return new MimoHandler(options) - case "zai": + case providerIdentifiers.zai: return new ZAiHandler(options) - case "fireworks": + case providerIdentifiers.fireworks: return new FireworksHandler(options) - case "friendli": + case providerIdentifiers.friendli: return new FriendliHandler(options) - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return new VercelAiGatewayHandler(options) - case "opencode-go": + case providerIdentifiers.opencodeGo: return new OpencodeGoHandler(options) - case "kenari": + case providerIdentifiers.kenari: return new KenariHandler(options) - case "zoo-gateway": + case providerIdentifiers.zooGateway: return new ZooGatewayHandler(options) - case "minimax": + case providerIdentifiers.minimax: return new MiniMaxHandler(options) - case "baseten": + case providerIdentifiers.baseten: return new BasetenHandler(options) - case "poe": + case providerIdentifiers.poe: return new PoeHandler(options) default: return new AnthropicHandler(options) diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index d697757853..706bce58e7 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1085,6 +1085,23 @@ describe("VertexHandler", () => { expect(model.info.supportsTemperature).toBe(false) }) + it("should return Claude Opus 5 model info", () => { + const handler = new AnthropicVertexHandler({ + apiModelId: "claude-opus-5", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const model = handler.getModel() + expect(model.id).toBe("claude-opus-5") + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.supportsReasoningBinary).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsTemperature).toBe(false) + }) + it("should not enable 1M context when flag is disabled", () => { const handler = new AnthropicVertexHandler({ apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], @@ -1392,6 +1409,35 @@ describe("VertexHandler", () => { expect(request.thinking).not.toHaveProperty("budget_tokens") expect(request.temperature).toBeUndefined() }) + + it("should use adaptive thinking for Claude Opus 5", async () => { + const opusHandler = new AnthropicVertexHandler({ + apiModelId: "claude-opus-5", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + enableReasoningEffort: true, + }) + + const mockCreate = vitest.fn().mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() { + yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 5 } } } + }, + })) + ;(opusHandler["client"].messages as any).create = mockCreate + + await opusHandler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "adaptive" }, + }), + undefined, + ) + + const request = mockCreate.mock.calls[0][0] + expect(request.thinking).not.toHaveProperty("budget_tokens") + expect(request.temperature).toBeUndefined() + }) }) describe("native tool calling", () => { diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index c17e486184..686d1949d8 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -456,6 +456,33 @@ describe("AnthropicHandler", () => { expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31") }) + it("should use adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => { + const opusHandler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-opus-5", + enableReasoningEffort: true, + modelMaxTokens: 32768, + }) + + const stream = opusHandler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + ]) + + for await (const _chunk of stream) { + // Consume stream + } + + const requestBody = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[0] + const requestOptions = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[1] + expect(requestBody?.thinking).toEqual({ type: "adaptive" }) + expect(requestBody?.temperature).toBeUndefined() + expect(requestBody?.max_tokens).toBe(32768) + expect(requestOptions?.headers?.["anthropic-beta"]).toContain("prompt-caching-2024-07-31") + }) + it("should send the custom model ID as-is and use adaptive thinking for a custom Sonnet-5-family model", async () => { const customHandler = new AnthropicHandler({ apiKey: "test-api-key", @@ -656,6 +683,23 @@ describe("AnthropicHandler", () => { expect(model.reasoningBudget).toBeUndefined() }) + it("should handle Claude Opus 5 model correctly", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-opus-5", + }) + const model = handler.getModel() + expect(model.id).toBe("claude-opus-5") + expect(model.info.maxTokens).toBe(128000) + expect(model.info.contextWindow).toBe(1000000) + expect(model.maxTokens).toBe(8192) + expect(model.info.supportsReasoningBinary).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsTemperature).toBe(false) + expect(model.reasoningBudget).toBeUndefined() + }) + it("should enable 1M context for Claude 4.5 Sonnet when beta flag is set", () => { const handler = new AnthropicHandler({ apiKey: "test-api-key", diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index e2b17e4e87..b025f33f02 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -18,6 +18,22 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) +vi.mock("../../../utils/networkProxy", () => ({ + getSystemProxyUrl: vi.fn().mockReturnValue(undefined), +})) + +vi.mock("@smithy/node-http-handler", () => ({ + NodeHttpHandler: vi.fn(), +})) + +vi.mock("http-proxy-agent", () => ({ + HttpProxyAgent: vi.fn(), +})) + +vi.mock("https-proxy-agent", () => ({ + HttpsProxyAgent: vi.fn(), +})) + // Mock BedrockRuntimeClient and ConverseStreamCommand vi.mock("@aws-sdk/client-bedrock-runtime", () => { const mockSend = vi.fn().mockResolvedValue({ @@ -42,14 +58,23 @@ import { BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_SERVICE_TIER_MODEL_IDS, bedrockModels, + SERVICE_TIER_KEY, ApiProviderError, } from "@roo-code/types" import type { Anthropic } from "@anthropic-ai/sdk" +import { getSystemProxyUrl } from "../../../utils/networkProxy" +import { NodeHttpHandler } from "@smithy/node-http-handler" +import { HttpProxyAgent } from "http-proxy-agent" +import { HttpsProxyAgent } from "https-proxy-agent" // Get access to the mocked functions const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) +const mockGetSystemProxyUrl = vi.mocked(getSystemProxyUrl) +const mockNodeHttpHandler = vi.mocked(NodeHttpHandler) +const mockHttpProxyAgent = vi.mocked(HttpProxyAgent) +const mockHttpsProxyAgent = vi.mocked(HttpsProxyAgent) describe("AwsBedrockHandler", () => { let handler: AwsBedrockHandler @@ -118,6 +143,95 @@ describe("AwsBedrockHandler", () => { }) }) + describe("proxy configuration", () => { + afterEach(() => { + mockGetSystemProxyUrl.mockReturnValue(undefined) + }) + + it("should configure NodeHttpHandler with HttpProxyAgent and HttpsProxyAgent when proxy URL is set", () => { + mockGetSystemProxyUrl.mockReturnValue("http://proxy.corp.local:3128") + + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + // Verify both proxy agents were created with the correct URL + expect(mockHttpProxyAgent).toHaveBeenCalledWith("http://proxy.corp.local:3128") + expect(mockHttpsProxyAgent).toHaveBeenCalledWith("http://proxy.corp.local:3128") + + // Verify NodeHttpHandler was created with both agents + expect(mockNodeHttpHandler).toHaveBeenCalledWith( + expect.objectContaining({ + httpAgent: expect.anything(), + httpsAgent: expect.anything(), + requestTimeout: 0, + }), + ) + + // Verify requestHandler was set on BedrockRuntimeClient config + expect(mockBedrockRuntimeClient).toHaveBeenLastCalledWith( + expect.objectContaining({ requestHandler: expect.anything() }), + ) + }) + + it("should not create a proxy requestHandler when no proxy is configured", () => { + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + expect(mockNodeHttpHandler).not.toHaveBeenCalled() + expect(mockBedrockRuntimeClient.mock.lastCall?.[0]?.requestHandler).toBeUndefined() + }) + + it("should apply proxy for API key authentication", () => { + mockGetSystemProxyUrl.mockReturnValue("http://proxy.corp.local:3128") + + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsUseApiKey: true, + awsApiKey: "test-api-key", + awsRegion: "us-east-1", + }) + + expect(mockNodeHttpHandler).toHaveBeenCalledWith( + expect.objectContaining({ + httpsAgent: expect.anything(), + requestTimeout: 0, + }), + ) + }) + + it("should pass a custom endpoint to getSystemProxyUrl for NO_PROXY matching", () => { + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + awsBedrockEndpoint: "https://bedrock.vpce.internal", + awsBedrockEndpointEnabled: true, + }) + + expect(mockGetSystemProxyUrl).toHaveBeenCalledWith("https://bedrock.vpce.internal") + }) + + it("should pass undefined to getSystemProxyUrl when no custom endpoint is set", () => { + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + expect(mockGetSystemProxyUrl).toHaveBeenCalledWith(undefined) + }) + }) + describe("region mapping and cross-region inference", () => { describe("getPrefixForRegion", () => { it("should return correct prefix for US regions", () => { @@ -755,6 +869,37 @@ describe("AwsBedrockHandler", () => { const model = handler.getModel() expect(model.id).toBe("global.anthropic.claude-sonnet-5") }) + + it("should return Claude Opus 5 model info", () => { + const handler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-opus-5", + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + }) + + const model = handler.getModel() + expect(model.id).toBe("anthropic.claude-opus-5") + expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.supportsReasoningBinary).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsTemperature).toBe(false) + expect(model.maxTokens).toBe(8192) + }) + + it("should apply global inference prefix for Claude Opus 5 when awsUseGlobalInference is true", () => { + const handler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-opus-5", + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsUseGlobalInference: true, + }) + + const model = handler.getModel() + expect(model.id).toBe("global.anthropic.claude-opus-5") + }) }) describe("1M context beta feature", () => { @@ -1089,10 +1234,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("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() } }) @@ -1119,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() } }) @@ -1150,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() } }) @@ -1179,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() } }) }) @@ -1487,6 +1632,36 @@ describe("AwsBedrockHandler", () => { expect(commandArg.inferenceConfig?.temperature).toBeUndefined() }) + it("should send adaptive thinking with effort xhigh for Claude Opus 5 when reasoning is enabled", async () => { + // End-to-end regression guard for the Opus 5 handler branch. The + // isAdaptiveThinkingModel predicate is unit-covered, but a regression in + // the createMessage adaptive-thinking branch for this specific model + // wouldn't be caught without a request-level test. + const opus5Handler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-opus-5", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + enableReasoningEffort: true, + }) + + const generator = opus5Handler.createMessage("System prompt", messages) + await generator.next() + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // Opus 5 uses the same adaptive-thinking contract as Opus 4.7/4.8 — + // budget_tokens causes a 400, so thinking.type is "adaptive" with effort. + expect(commandArg.additionalModelRequestFields?.thinking).toEqual({ + type: "adaptive", + display: "summarized", + }) + expect(commandArg.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }) + // Opus 5 rejects sampling parameters: temperature must be omitted entirely. + expect(commandArg.inferenceConfig?.temperature).toBeUndefined() + }) + it("should omit thinking and temperature for Claude Opus 4.8 when reasoning is disabled", async () => { const opus48Handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-opus-4-8", @@ -1620,6 +1795,7 @@ describe("AwsBedrockHandler", () => { expect(isAdaptiveThinkingModel("anthropic.claude-opus-4-8")).toBe(true) expect(isAdaptiveThinkingModel("anthropic.claude-fable-5")).toBe(true) expect(isAdaptiveThinkingModel("anthropic.claude-sonnet-5")).toBe(true) + expect(isAdaptiveThinkingModel("anthropic.claude-opus-5")).toBe(true) // Future-proof Sonnet patterns — guarded even before a registry entry exists. expect(isAdaptiveThinkingModel("anthropic.claude-sonnet-4-7")).toBe(true) expect(isAdaptiveThinkingModel("anthropic.claude-sonnet-4-8")).toBe(true) @@ -1629,12 +1805,14 @@ describe("AwsBedrockHandler", () => { expect(isAdaptiveThinkingModel("us.anthropic.claude-opus-4-8")).toBe(true) expect(isAdaptiveThinkingModel("global.anthropic.claude-fable-5")).toBe(true) expect(isAdaptiveThinkingModel("global.anthropic.claude-sonnet-5")).toBe(true) + expect(isAdaptiveThinkingModel("global.anthropic.claude-opus-5")).toBe(true) expect(isAdaptiveThinkingModel("eu.anthropic.claude-sonnet-4-7")).toBe(true) expect(isAdaptiveThinkingModel("global.anthropic.claude-opus-4-8")).toBe(true) }) it("returns false for older / non-adaptive models", () => { expect(isAdaptiveThinkingModel("anthropic.claude-opus-4-6-v1")).toBe(false) + expect(isAdaptiveThinkingModel("anthropic.claude-opus-4-5-20251101-v1:0")).toBe(false) expect(isAdaptiveThinkingModel("anthropic.claude-sonnet-4-6")).toBe(false) expect(isAdaptiveThinkingModel("anthropic.claude-3-5-sonnet-20241022-v2:0")).toBe(false) expect(isAdaptiveThinkingModel("amazon.nova-lite-v1:0")).toBe(false) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 563b49bce8..cc80e8769c 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -225,6 +225,7 @@ describe("DeepSeekHandler", () => { expect(model.id).toBe("deepseek-v4-flash") expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.supportsImages).toBe(true) expect((model.info as ModelInfo).supportsReasoningEffort).toContain("xhigh") }) @@ -252,6 +253,7 @@ describe("DeepSeekHandler", () => { expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.supportsImages).toBe(true) expect(model.info.supportsPromptCache).toBe(true) expect((model.info as ModelInfo).preserveReasoning).toBe(true) expect((model.info as ModelInfo).reasoningEffort).toBe("high") 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__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts new file mode 100644 index 0000000000..d78c252304 --- /dev/null +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -0,0 +1,236 @@ +import { buildApiHandler } from "../../index" +import { KimiCodeHandler } from "../kimi-code" + +const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({ + mockGetAccessToken: vi.fn(), + mockForceRefreshAccessToken: vi.fn(), + mockGetModels: vi.fn(), +})) + +vi.mock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + getAccessToken: mockGetAccessToken, + forceRefreshAccessToken: mockForceRefreshAccessToken, + }, +})) + +vi.mock("../fetchers/modelCache", () => ({ getModels: mockGetModels })) + +describe("KimiCodeHandler", () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetAccessToken.mockResolvedValue("oauth-token") + mockForceRefreshAccessToken.mockResolvedValue("refreshed-token") + mockGetModels.mockRejectedValue(new Error("offline")) + }) + + it("is dispatched separately from Moonshot and preserves an unknown selected model", () => { + const handler = buildApiHandler({ + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "kimi-key", + apiModelId: "future-kimi-model", + }) + expect(handler).toBeInstanceOf(KimiCodeHandler) + expect(handler.getModel().id).toBe("future-kimi-model") + }) + + it("uses kimi-for-coding only when no model is selected", () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "kimi-key" }) + expect(handler.getModel().id).toBe("kimi-for-coding") + }) + + it("uses API key when auth method is api-key", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "my-api-key" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ choices: [{ message: { content: "response" }, finish_reason: "stop" }] }), { + status: 200, + }), + ) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected - mock is incomplete + } + expect(mockGetAccessToken).not.toHaveBeenCalled() + }) + + it("uses OAuth token when auth method is oauth or not specified", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected - mock will fail + } + expect(mockGetAccessToken).toHaveBeenCalled() + }) + + it("throws error when OAuth is required but no token available", async () => { + mockGetAccessToken.mockResolvedValueOnce(null) + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + await expect(async () => { + for await (const chunk of gen) { + // consume + } + }).rejects.toThrow("Not authenticated with Kimi Code") + }) + + it("throws error when API key auth is missing the key", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + await expect(async () => { + for await (const chunk of gen) { + // consume + } + }).rejects.toThrow("Kimi Code API key is required") + }) + + it("retries with forced refresh on 401 when using OAuth", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 })) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }), { + status: 200, + }), + ) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected - mock is incomplete + } + expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() + }) + + it("force-refreshes and retries exactly once after a non-streaming OAuth 401", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 }) + const createCompletion = vi + .spyOn((handler as any).client.chat.completions, "create") + .mockRejectedValueOnce(unauthorized) + .mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] }) + + await expect(handler.completePrompt("test")).resolves.toBe("retried") + expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() + expect(createCompletion).toHaveBeenCalledTimes(2) + }) + + it("does not retry on 401 when using API key auth", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 401 })) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + await expect(async () => { + for await (const chunk of gen) { + // consume + } + }).rejects.toThrow() + expect(mockForceRefreshAccessToken).not.toHaveBeenCalled() + }) + + it("does not force-refresh on non-401 OAuth failures", async () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" }) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 500 })) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + await expect(async () => { + for await (const chunk of gen) { + // consume + } + }).rejects.toThrow() + expect(mockForceRefreshAccessToken).not.toHaveBeenCalled() + }) + + it("fetches models during prepareRequest", async () => { + mockGetModels.mockResolvedValueOnce({ "test-model": { maxTokens: 1000 } }) + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected + } + expect(mockGetModels).toHaveBeenCalled() + }) + + it("continues when model discovery fails", async () => { + mockGetModels.mockRejectedValueOnce(new Error("discovery failed")) + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected - different error + } + expect(mockGetModels).toHaveBeenCalled() + }) + + it.each([ + ["failure", () => Promise.reject(new Error("offline"))], + ["empty response", () => Promise.resolve({})], + ])("does not repeatedly block requests after model discovery %s", async (_case, discovery) => { + mockGetModels.mockImplementationOnce(discovery) + vi.spyOn(globalThis, "fetch").mockImplementation( + async () => new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 }), + ) + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + + await handler.completePrompt("first") + await handler.completePrompt("second") + + expect(mockGetModels).toHaveBeenCalledOnce() + }) + + it("uses discovered model info when available", async () => { + mockGetModels.mockResolvedValueOnce({ "kimi-for-coding": { maxTokens: 8000, contextWindow: 128000 } }) + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) + try { + for await (const chunk of gen) { + // consume + } + } catch { + // expected + } + const model = handler.getModel() + expect(model.info.maxTokens).toBe(8000) + }) + + it("defaults to max reasoning effort and advertises low/high/max support", () => { + const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) + const model = handler.getModel() + expect(model.info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(model.info.requiredReasoningEffort).toBe(true) + expect(model.reasoning).toEqual({ reasoning_effort: "max" }) + }) + + it("sends the user-selected reasoning effort", () => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + reasoningEffort: "low", + }) + expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "low" }) + }) + + it("falls back to the model default when a persisted effort from another provider is unsupported", () => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + reasoningEffort: "medium", + }) + expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "max" }) + }) +}) diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index ab2f261058..a5898513c5 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -1124,6 +1124,145 @@ describe("LiteLLMHandler", () => { }) }) + describe("preserveReasoning message conversion", () => { + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ delta: { content: "ok" } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + } + }, + } + + it("uses convertToR1Format (merging tool-result text) when the model info sets preserveReasoning", async () => { + const optionsWithReasoning: ApiHandlerOptions = { + ...mockOptions, + litellmModelId: "deepseek-reasoner-alias", + } + handler = new LiteLLMHandler(optionsWithReasoning) + + vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ + id: "deepseek-reasoner-alias", + info: { ...litellmDefaultModelInfo, preserveReasoning: true }, + }) + + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll help." }, + { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, + ], + // DeepSeek-style interleaved thinking: must be echoed back in the next request. + reasoning_content: "Let me check the file first.", + } as Anthropic.Messages.MessageParam & { reasoning_content: string }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }, + { type: "text", text: "Thanks, continue." }, + ], + }, + ] + + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + }) + + const generator = handler.createMessage(systemPrompt, messages) + for await (const _chunk of generator) { + // Consume + } + + const createCall = mockCreate.mock.calls[0][0] + + // convertToR1Format with mergeToolResultText folds the trailing text into the + // tool message instead of appending a separate user message. + const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") + expect(toolMessage).toBeDefined() + expect(toolMessage.content).toBe("file contents\n\nThanks, continue.") + + const trailingUserMessage = createCall.messages.find( + (msg: any) => msg.role === "user" && msg.content === "Thanks, continue.", + ) + expect(trailingUserMessage).toBeUndefined() + + // The whole point of routing through convertToR1Format: reasoning_content + // must survive on the assistant message so the model doesn't reject the + // follow-up request for missing prior reasoning. + const assistantMessage = createCall.messages.find( + (msg: any) => msg.role === "assistant" && msg.tool_calls?.length > 0, + ) + expect(assistantMessage).toBeDefined() + expect(assistantMessage.reasoning_content).toBe("Let me check the file first.") + }) + + it("uses convertToOpenAiMessages (no merging) when the model info does not set preserveReasoning", async () => { + vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ + id: litellmDefaultModelId, + info: { ...litellmDefaultModelInfo, preserveReasoning: undefined }, + }) + + const systemPrompt = "You are a helpful assistant" + // Task.buildCleanConversationHistory() (src/core/task/Task.ts) already strips the + // reasoning content block before messages reach this handler when the model's + // preserveReasoning is not true, so no reasoning_content/reasoning block is present + // on the assistant message here — this input reflects what the handler actually + // receives in that case. + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll help." }, + { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }, + { type: "text", text: "Thanks, continue." }, + ], + }, + ] + + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + }) + + const generator = handler.createMessage(systemPrompt, messages) + for await (const _chunk of generator) { + // Consume + } + + const createCall = mockCreate.mock.calls[0][0] + + const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") + expect(toolMessage).toBeDefined() + expect(toolMessage.content).toBe("file contents") + + const trailingUserMessage = createCall.messages.find( + (msg: any) => + msg.role === "user" && + (msg.content === "Thanks, continue." || + (Array.isArray(msg.content) && + msg.content.some((part: any) => part.text === "Thanks, continue."))), + ) + expect(trailingUserMessage).toBeDefined() + + // No reasoning_content is sent to the API in this branch, matching what + // buildCleanConversationHistory already stripped upstream. + const assistantMessage = createCall.messages.find( + (msg: any) => msg.role === "assistant" && msg.tool_calls?.length > 0, + ) + expect(assistantMessage).toBeDefined() + expect(assistantMessage.reasoning_content).toBeUndefined() + }) + }) + describe("session ID header", () => { const mockStream = { async *[Symbol.asyncIterator]() { 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__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index d87ae1190b..5f90d2b818 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -89,6 +89,23 @@ describe("MiniMaxHandler", () => { expect(model.info).toEqual(minimaxModels[testModelId]) }) + it("should return MiniMax-M3 model with correct configuration", () => { + const testModelId: MinimaxModelId = "MiniMax-M3" + const handlerWithModel = new MiniMaxHandler({ + apiModelId: testModelId, + minimaxApiKey: "test-minimax-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(minimaxModels[testModelId]) + expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.maxTokens).toBe(16_384) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.cacheWritesPrice).toBe(0.375) + expect(model.info.cacheReadsPrice).toBe(0.06) + }) + it("should return MiniMax-M2.5 model with correct configuration", () => { const testModelId: MinimaxModelId = "MiniMax-M2.5" const handlerWithModel = new MiniMaxHandler({ @@ -193,10 +210,10 @@ describe("MiniMaxHandler", () => { expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) }) - it("should default to MiniMax-M2.7 model", () => { + it("should default to MiniMax-M3 model", () => { const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) const model = handlerDefault.getModel() - expect(model.id).toBe("MiniMax-M2.7") + expect(model.id).toBe("MiniMax-M3") }) }) @@ -398,6 +415,18 @@ describe("MiniMaxHandler", () => { }) describe("Model Configuration", () => { + it("should correctly configure MiniMax-M3 model properties", () => { + const model = minimaxModels["MiniMax-M3"] + expect(model.maxTokens).toBe(16_384) + expect(model.contextWindow).toBe(1_000_000) + expect(model.supportsImages).toBe(true) + expect(model.supportsPromptCache).toBe(true) + expect(model.inputPrice).toBe(0.3) + expect(model.outputPrice).toBe(1.2) + expect(model.cacheWritesPrice).toBe(0.375) + expect(model.cacheReadsPrice).toBe(0.06) + }) + it("should correctly configure MiniMax-M2 model properties", () => { const model = minimaxModels["MiniMax-M2"] expect(model.maxTokens).toBe(16_384) diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index c0fd832a19..ab8f818697 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -1,28 +1,3 @@ -// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls -const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ - mockStreamText: vi.fn(), - mockGenerateText: vi.fn(), -})) - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - streamText: mockStreamText, - generateText: mockGenerateText, - } -}) - -vi.mock("@ai-sdk/openai-compatible", () => ({ - createOpenAICompatible: vi.fn(function () { - // Return a function that returns a mock language model - return vi.fn(() => ({ - modelId: "moonshot-chat", - provider: "moonshot", - })) - }), -})) - import type { Anthropic } from "@anthropic-ai/sdk" import { moonshotDefaultModelId } from "@roo-code/types" @@ -38,7 +13,7 @@ describe("MoonshotHandler", () => { beforeEach(() => { mockOptions = { moonshotApiKey: "test-api-key", - apiModelId: "moonshot-chat", + apiModelId: "kimi-k2-0905-preview", moonshotBaseUrl: "https://api.moonshot.ai/v1", } handler = new MoonshotHandler(mockOptions) @@ -96,9 +71,16 @@ describe("MoonshotHandler", () => { const model = handlerWithInvalidModel.getModel() expect(model.id).toBe("invalid-model") // Returns provided ID expect(model.info).toBeDefined() - // Should have the same base properties as default model + // Should have the same structural properties as default model expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow) expect(model.info.supportsPromptCache).toBe(true) + // Unknown models should not send a guessed maxTokens to the API + expect(model.info.maxTokens).toBeUndefined() + // Pricing should be unknown for unrecognized models + expect(model.info.inputPrice).toBeUndefined() + expect(model.info.outputPrice).toBeUndefined() + expect(model.info.cacheReadsPrice).toBeUndefined() + expect(model.info.cacheWritesPrice).toBeUndefined() }) it("should return default model if no model ID is provided", () => { @@ -134,23 +116,22 @@ describe("MoonshotHandler", () => { ] it("should handle streaming responses", async () => { - // Mock the fullStream async generator - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: null }], + usage: null, + } } - // Mock usage promise - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: { cachedInputTokens: undefined }, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -158,28 +139,28 @@ describe("MoonshotHandler", () => { chunks.push(chunk) } - expect(chunks.length).toBeGreaterThan(0) const textChunks = chunks.filter((chunk) => chunk.type === "text") expect(textChunks).toHaveLength(1) expect(textChunks[0].text).toBe("Test response") }) it("should include usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -194,21 +175,26 @@ describe("MoonshotHandler", () => { }) it("should include cache metrics in usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: "stop" }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 2 }, + }, + } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -224,25 +210,34 @@ describe("MoonshotHandler", () => { }) describe("completePrompt", () => { - it("should complete a prompt using generateText", async () => { - mockGenerateText.mockResolvedValue({ - text: "Test completion", - }) + it("should complete a prompt using the OpenAI client", async () => { + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ message: { content: "Test completion" } }], + }), + }, + }, + } + + ;(handler as any).client = mockClient const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test completion") - expect(mockGenerateText).toHaveBeenCalledWith( + expect(mockClient.chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ - prompt: "Test prompt", + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "Test prompt" }], }), + {}, ) }) }) describe("processUsageMetrics", () => { it("should correctly process usage metrics including cache information", () => { - // We need to access the protected method, so we'll create a test subclass class TestMoonshotHandler extends MoonshotHandler { public testProcessUsageMetrics(usage: any) { return this.processUsageMetrics(usage) @@ -252,10 +247,9 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - inputTokens: 100, - outputTokens: 50, - details: {}, - raw: { + prompt_tokens: 100, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 20, }, } @@ -279,10 +273,8 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - inputTokens: 100, - outputTokens: 50, - details: {}, - raw: {}, + prompt_tokens: 100, + completion_tokens: 50, } const result = testHandler.testProcessUsageMetrics(usage) @@ -293,27 +285,64 @@ describe("MoonshotHandler", () => { expect(result.cacheWriteTokens).toBe(0) expect(result.cacheReadTokens).toBeUndefined() }) + + it("should handle cached_tokens at top level (not in prompt_tokens_details)", () => { + class TestMoonshotHandler extends MoonshotHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + const testHandler = new TestMoonshotHandler(mockOptions) + + const usage = { + prompt_tokens: 100, + completion_tokens: 50, + cached_tokens: 15, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.cacheReadTokens).toBe(15) + }) + + it("should handle null usage gracefully", () => { + class TestMoonshotHandler extends MoonshotHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + const testHandler = new TestMoonshotHandler(mockOptions) + + const result = testHandler.testProcessUsageMetrics(null) + + expect(result.inputTokens).toBe(0) + expect(result.outputTokens).toBe(0) + expect(result.cacheReadTokens).toBeUndefined() + }) }) - describe("getMaxOutputTokens", () => { - it("should return maxTokens from model info", () => { + describe("addMaxTokensIfNeeded", () => { + it("should use max_tokens (not max_completion_tokens) for Moonshot", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } const testHandler = new TestMoonshotHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, handler.getModel().info) - // Default model maxTokens is 16384 - expect(result).toBe(16384) + expect(requestOptions.max_tokens).toBe(16384) + expect(requestOptions.max_completion_tokens).toBeUndefined() }) - it("should use modelMaxTokens when provided", () => { + it("should use modelMaxTokens override when provided", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } @@ -322,23 +351,27 @@ describe("MoonshotHandler", () => { ...mockOptions, modelMaxTokens: customMaxTokens, }) + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, handler.getModel().info) - const result = testHandler.testGetMaxOutputTokens() - expect(result).toBe(customMaxTokens) + expect(requestOptions.max_tokens).toBe(customMaxTokens) }) - it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { + it("should not send maxTokens for unknown model IDs", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } - const testHandler = new TestMoonshotHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() + const testHandler = new TestMoonshotHandler({ + ...mockOptions, + apiModelId: "future-moonshot-model", + }) + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, testHandler.getModel().info) - // moonshot-chat has maxTokens of 16384 - expect(result).toBe(16384) + expect(requestOptions.max_tokens).toBeUndefined() }) }) @@ -352,94 +385,39 @@ describe("MoonshotHandler", () => { ] it("should handle tool calls in streaming", async () => { - async function* mockFullStream() { - yield { - type: "tool-input-start", - id: "tool-call-1", - toolName: "read_file", - } + async function* mockStream() { yield { - type: "tool-input-delta", - id: "tool-call-1", - delta: '{"path":"test.ts"}', - } - yield { - type: "tool-input-end", - id: "tool-call-1", - } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: {}, - }) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], + choices: [ + { + delta: { + content: null, + tool_calls: [ + { + index: 0, + id: "tool-call-1", + function: { + name: "read_file", + arguments: '{"path":"test.ts"}', + }, + }, + ], }, + finish_reason: "tool_calls", }, - }, - ], - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") - const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") - const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") - - expect(toolCallStartChunks.length).toBe(1) - expect(toolCallStartChunks[0].id).toBe("tool-call-1") - expect(toolCallStartChunks[0].name).toBe("read_file") - - expect(toolCallDeltaChunks.length).toBe(1) - expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') - - expect(toolCallEndChunks.length).toBe(1) - expect(toolCallEndChunks[0].id).toBe("tool-call-1") - }) - - it("should handle complete tool calls", async () => { - async function* mockFullStream() { - yield { - type: "tool-call", - toolCallId: "tool-call-1", - toolName: "read_file", - input: { path: "test.ts" }, + ], + usage: null, } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: {}, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task", @@ -464,11 +442,16 @@ describe("MoonshotHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((c) => c.type === "tool_call") - expect(toolCallChunks.length).toBe(1) - expect(toolCallChunks[0].id).toBe("tool-call-1") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') + const partialChunks = chunks.filter((c) => c.type === "tool_call_partial") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(partialChunks.length).toBe(1) + expect(partialChunks[0].id).toBe("tool-call-1") + expect(partialChunks[0].name).toBe("read_file") + expect(partialChunks[0].arguments).toBe('{"path":"test.ts"}') + + expect(endChunks.length).toBe(1) + expect(endChunks[0].id).toBe("tool-call-1") }) }) }) diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts index 80ab4e1887..2ad5dc8d61 100644 --- a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts +++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts @@ -515,6 +515,22 @@ describe("OpenAiCodexHandler native tool calls", () => { await expect(handler.completePrompt("Test prompt")).resolves.toBe("done") + const fetchOptions = mockFetch.mock.calls[0][1] + const body = JSON.parse(fetchOptions.body) + expect(body.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "Test prompt" }], + }, + ]) + expect(body).not.toHaveProperty("prompt_cache_key") + expect(body.reasoning?.context).toBeUndefined() + expect(body.input).not.toContainEqual(expect.objectContaining({ type: "additional_tools" })) + expect(body.input).not.toContainEqual(expect.objectContaining({ role: "developer" })) + expect(fetchOptions.headers).not.toHaveProperty("session-id") + expect(fetchOptions.headers).not.toHaveProperty("x-session-affinity") + expect(fetchOptions.headers).not.toHaveProperty("version") + expect(fetchOptions.headers).not.toHaveProperty("x-openai-internal-codex-responses-lite") expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/responses"), expect.objectContaining({ diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 22466af3ce..dbe13d576b 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -1,9 +1,40 @@ // npx vitest run api/providers/__tests__/openai-codex.spec.ts +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, +})) + import { Anthropic } from "@anthropic-ai/sdk" -import { OpenAiCodexHandler } from "../openai-codex" +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" +function createCompletedStream() { + return { + async *[Symbol.asyncIterator]() { + yield { + type: "response.completed", + response: { + id: "response-1", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + } + }, + } +} + +async function drainStream(stream: AsyncIterable) { + for await (const _chunk of stream) { + // Drain the response stream. + } +} + describe("OpenAiCodexHandler.getModel", () => { it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini", "gpt-5.3-codex-spark"])( "should return specified model when a valid model id is provided: %s", @@ -46,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" }) @@ -145,3 +254,407 @@ describe("OpenAiCodexHandler.createMessage", () => { }) }) }) + +describe("OpenAiCodexHandler.completePrompt service tier", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it.each<[string, OpenAiCodexServiceTier | undefined, typeof 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" } }] + const input = [ + { + role: "user", + content: [ + { type: "input_text", text: "Inspect the image", detail: "keep-text-detail" }, + { + type: "input_image", + image_url: "data:image/png;base64,abc", + detail: "high", + metadata: { detail: "keep-nested-detail" }, + }, + ], + detail: "keep-message-detail", + }, + { + type: "wrapper", + detail: "keep-wrapper-detail", + items: [{ type: "input_image", image_url: "nested", detail: "low", extra: true }], + }, + ] + const body = { + model: "gpt-5.6-luna", + input, + stream: true, + store: false, + instructions: "Follow these exact instructions.", + tools, + tool_choice: { type: "function", name: "read_file" }, + parallel_tool_calls: true, + reasoning: { effort: "high", summary: "auto" }, + include: ["reasoning.encrypted_content"], + custom_field: { preserved: true }, + } + + expect(transformLunaResponsesLiteBody(body, "task-123")).toEqual({ + model: "gpt-5.6-luna", + input: [ + { type: "additional_tools", role: "developer", tools }, + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Follow these exact instructions." }], + }, + { + role: "user", + content: [ + { type: "input_text", text: "Inspect the image", detail: "keep-text-detail" }, + { + type: "input_image", + image_url: "data:image/png;base64,abc", + metadata: { detail: "keep-nested-detail" }, + }, + ], + detail: "keep-message-detail", + }, + { + type: "wrapper", + detail: "keep-wrapper-detail", + items: [{ type: "input_image", image_url: "nested", extra: true }], + }, + ], + stream: true, + store: false, + tool_choice: "auto", + parallel_tool_calls: false, + prompt_cache_key: "task-123", + reasoning: { effort: "high", summary: "auto", context: "all_turns" }, + include: ["reasoning.encrypted_content"], + custom_field: { preserved: true }, + }) + }) + + it("uses empty additional tools, omits an empty instruction message, and creates reasoning context", () => { + const input = [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }] + + expect( + transformLunaResponsesLiteBody( + { + model: "gpt-5.6-luna", + input, + instructions: "", + stream: false, + }, + "session-fallback", + ), + ).toEqual({ + model: "gpt-5.6-luna", + input: [{ type: "additional_tools", role: "developer", tools: [] }, ...input], + stream: false, + tool_choice: "auto", + parallel_tool_calls: false, + prompt_cache_key: "session-fallback", + reasoning: { context: "all_turns" }, + }) + }) + + it("overwrites a pre-existing reasoning context with all_turns", () => { + const result = transformLunaResponsesLiteBody( + { + model: "gpt-5.6-luna", + input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }], + reasoning: { effort: "high", context: "current_turn" }, + }, + "session-1", + ) + + expect(result.reasoning).toEqual({ effort: "high", context: "all_turns" }) + }) + + it.each([ + ["input", { input: "invalid" }, "input must be an array"], + ["tools", { input: [], tools: {} }, "tools must be an array when provided"], + ["instructions", { input: [], instructions: [] }, "instructions must be a string when provided"], + ])("rejects malformed %s locally", (_field, body, expectedMessage) => { + expect(() => transformLunaResponsesLiteBody(body, "session-1")).toThrow(expectedMessage) + }) +}) + +describe("OpenAiCodexHandler Luna Responses Lite requests", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it("uses a single task session ID in the Luna SDK body and headers", async () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna", reasoningEffort: "high" }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + ;(handler as any).client = { responses: { create: mockCreate } } + + await drainStream( + handler.createMessage("Luna instructions", [{ role: "user", content: "Hello" }], { + taskId: "task-luna", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "read_file" } }, + parallelToolCalls: true, + }), + ) + + const [body, options] = mockCreate.mock.calls[0] + expect(body).toMatchObject({ + model: "gpt-5.6-luna", + prompt_cache_key: "task-luna", + tool_choice: "auto", + parallel_tool_calls: false, + reasoning: { effort: "high", summary: "auto", context: "all_turns" }, + }) + expect(body).not.toHaveProperty("tools") + expect(body).not.toHaveProperty("instructions") + expect(body.input).toHaveLength(3) + expect(body.input[0]).toMatchObject({ type: "additional_tools", role: "developer" }) + expect(body.input[1]).toEqual({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Luna instructions" }], + }) + expect(body.input[2]).toEqual({ + role: "user", + content: [{ type: "input_text", text: "Hello" }], + }) + expect(options.headers).toMatchObject({ + originator: "zoo-code", + session_id: "task-luna", + "session-id": "task-luna", + "x-session-affinity": "task-luna", + version: "0.144.0", + "x-openai-internal-codex-responses-lite": "true", + "ChatGPT-Account-Id": "acct_test", + }) + }) + + it("reuses the unchanged Luna body and headers in the manual SSE fallback", async () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna" }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + let sdkBody: any + const mockCreate = vitest.fn().mockImplementation((body: any) => { + sdkBody = body + throw new Error("SDK unavailable") + }) + ;(handler as any).client = { responses: { create: mockCreate } } + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.completed","response":{"id":"response-1","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ), + ) + controller.close() + }, + }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await drainStream( + handler.createMessage("Instructions", [{ role: "user", content: "Fallback" }], { + taskId: "task-fallback", + tools: [], + }), + ) + + const fetchOptions = mockFetch.mock.calls[0][1] + expect(JSON.parse(fetchOptions.body)).toEqual(sdkBody) + expect(fetchOptions.headers).toMatchObject({ + Authorization: "Bearer test-token", + session_id: "task-fallback", + "session-id": "task-fallback", + "x-session-affinity": "task-fallback", + version: "0.144.0", + "x-openai-internal-codex-responses-lite": "true", + }) + expect(sdkBody.prompt_cache_key).toBe("task-fallback") + }) + + it("preserves Luna session affinity while retrying with refreshed authentication", async () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna" }) + const transformSpy = vitest.spyOn(handler as any, "buildLunaRequestBody") + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("expired-token") + vitest.spyOn(openAiCodexOAuthManager, "forceRefreshAccessToken").mockResolvedValue("refreshed-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { create: vitest.fn().mockRejectedValue(new Error("SDK unavailable")) }, + } + const mockFetch = vitest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 401, + text: vitest.fn().mockResolvedValue('{"error":{"message":"Codex API invalid token"}}'), + }) + .mockResolvedValueOnce({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.completed","response":{"id":"response-1","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ), + ) + controller.close() + }, + }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await drainStream( + handler.createMessage("Instructions", [{ role: "user", content: "Retry" }], { + taskId: "task-retry", + tools: [], + }), + ) + + expect(openAiCodexOAuthManager.forceRefreshAccessToken).toHaveBeenCalledOnce() + expect(mockFetch).toHaveBeenCalledTimes(2) + const firstOptions = mockFetch.mock.calls[0][1] + const retryOptions = mockFetch.mock.calls[1][1] + const firstBody = JSON.parse(firstOptions.body) + const retryBody = JSON.parse(retryOptions.body) + + expect(retryBody).toEqual(firstBody) + expect(transformSpy).toHaveBeenCalledTimes(1) + expect(firstBody.prompt_cache_key).toBe("task-retry") + expect(firstOptions.headers).toMatchObject({ + Authorization: "Bearer expired-token", + "session-id": "task-retry", + "x-session-affinity": "task-retry", + version: "0.144.0", + "x-openai-internal-codex-responses-lite": "true", + }) + expect(retryOptions.headers).toMatchObject({ + Authorization: "Bearer refreshed-token", + "session-id": "task-retry", + "x-session-affinity": "task-retry", + version: "0.144.0", + "x-openai-internal-codex-responses-lite": "true", + }) + }) + + it.each(["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna-alias"])( + "does not apply Luna behavior to %s", + async (apiModelId) => { + const handler = new OpenAiCodexHandler({ apiModelId }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + ;(handler as any).client = { responses: { create: mockCreate } } + + await drainStream( + handler.createMessage("Normal instructions", [{ role: "user", content: "Hello" }], { + taskId: "task-normal", + tools: [], + tool_choice: "required", + parallelToolCalls: true, + }), + ) + + const [body, options] = mockCreate.mock.calls[0] + expect(body.instructions).toBe("Normal instructions") + expect(body.tools).toEqual([]) + expect(body.tool_choice).toBe("required") + expect(body.parallel_tool_calls).toBe(true) + expect(body).not.toHaveProperty("prompt_cache_key") + expect(body.reasoning?.context).toBeUndefined() + expect(options.headers).not.toHaveProperty("session-id") + expect(options.headers).not.toHaveProperty("x-session-affinity") + expect(options.headers).not.toHaveProperty("version") + expect(options.headers).not.toHaveProperty("x-openai-internal-codex-responses-lite") + }, + ) + + it("uses the handler session for reasoning-disabled Luna completePrompt requests", async () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna", reasoningEffort: "disable" }) + 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({ + output: [ + { + type: "message", + content: [{ type: "output_text", text: "Complete" }], + }, + ], + }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await expect(handler.completePrompt("Hello Luna")).resolves.toBe("Complete") + + const fetchOptions = mockFetch.mock.calls[0][1] + const body = JSON.parse(fetchOptions.body) + const sessionId = body.prompt_cache_key + expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(body).toMatchObject({ + model: "gpt-5.6-luna", + stream: false, + tool_choice: "auto", + parallel_tool_calls: false, + reasoning: { context: "all_turns" }, + }) + expect(body).not.toHaveProperty("include") + expect(body.input).toEqual([ + { type: "additional_tools", role: "developer", tools: [] }, + { role: "user", content: [{ type: "input_text", text: "Hello Luna" }] }, + ]) + expect(fetchOptions.headers).toMatchObject({ + session_id: sessionId, + "session-id": sessionId, + "x-session-affinity": sessionId, + version: "0.144.0", + "x-openai-internal-codex-responses-lite": "true", + }) + }) +}) 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__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 332fc39602..72d219e02a 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -453,6 +453,27 @@ describe("OpenAiHandler", () => { expect(callArgs.reasoning_effort).toBe("high") }) + it("should pass through max reasoning_effort when configured by an OpenAI-compatible model", async () => { + const reasoningOptions: ApiHandlerOptions = { + ...mockOptions, + enableReasoningEffort: true, + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + reasoningEffort: "max", + }, + } + const reasoningHandler = new OpenAiHandler(reasoningOptions) + const stream = reasoningHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.reasoning_effort).toBe("max") + }) + it("should not include reasoning_effort when reasoning effort is disabled", async () => { const noReasoningOptions: ApiHandlerOptions = { ...mockOptions, @@ -905,6 +926,12 @@ describe("OpenAiHandler", () => { await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error: API Error") }) + it("should preserve HTTP status when wrapping completion errors", async () => { + mockCreate.mockRejectedValueOnce(Object.assign(new Error("Unauthorized"), { status: 401 })) + + await expect(handler.completePrompt("Test prompt")).rejects.toMatchObject({ status: 401 }) + }) + it("should handle empty response", async () => { mockCreate.mockImplementationOnce(() => ({ choices: [{ message: { content: "" } }], diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 5d829f2374..38e0c33d95 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -78,6 +78,20 @@ vitest.mock("../fetchers/modelCache", () => ({ cacheReadsPrice: 0.3, description: "Claude Sonnet 5", }, + "anthropic/claude-opus-5": { + maxTokens: 128000, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + inputPrice: 5, + outputPrice: 25, + cacheWritesPrice: 6.25, + cacheReadsPrice: 0.5, + description: "Claude Opus 5", + }, }) }), })) @@ -301,6 +315,39 @@ describe("RequestyHandler", () => { ) }) + it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => { + const handler = new RequestyHandler({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-opus-5", + enableReasoningEffort: true, + modelMaxTokens: 32768, + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + + mockCreate.mockResolvedValue(mockStream) + + const generator = handler.createMessage("test system prompt", [{ role: "user" as const, content: "test" }]) + await generator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "anthropic/claude-opus-5", + max_tokens: 32768, + thinking: { type: "adaptive" }, + temperature: undefined, + }), + ) + }) + it("handles API errors", async () => { const handler = new RequestyHandler(mockOptions) const mockError = new Error("API Error") @@ -587,6 +634,23 @@ describe("RequestyHandler", () => { }) }) + it("omits temperature for Claude Opus 5 in completePrompt", async () => { + const handler = new RequestyHandler({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-opus-5", + }) + mockCreate.mockResolvedValue({ choices: [{ message: { content: "test completion" } }] }) + + await handler.completePrompt("test prompt") + + expect(mockCreate).toHaveBeenCalledWith({ + model: "anthropic/claude-opus-5", + max_tokens: 8192, + messages: [{ role: "system", content: "test prompt" }], + temperature: undefined, + }) + }) + it("handles API errors", async () => { const handler = new RequestyHandler(mockOptions) const mockError = new Error("API Error") diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 4669904e2f..ad14486262 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -61,6 +61,18 @@ vitest.mock("../fetchers/modelCache", () => ({ cacheReadsPrice: 0.3, description: "Claude Sonnet 5", }, + "anthropic/claude-opus-5": { + maxTokens: 128000, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + supportsTemperature: false, + inputPrice: 5, + outputPrice: 25, + cacheWritesPrice: 6.25, + cacheReadsPrice: 0.5, + description: "Claude Opus 5", + }, "anthropic/claude-3.5-haiku": { maxTokens: 32000, contextWindow: 200000, @@ -335,6 +347,24 @@ describe("VercelAiGatewayHandler", () => { expect(call.max_completion_tokens).toBe(128000) }) + it("omits temperature for Claude Opus 5", async () => { + const handler = new VercelAiGatewayHandler({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-opus-5", + }) + + await handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]).next() + + // Assert directly on the extracted call arg. `objectContaining({ + // temperature: undefined })` passes whether temperature is explicitly + // undefined or simply absent, so it wouldn't catch a regression where the + // handler stops consulting supportsTemperature. + const call = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + expect(call.model).toBe("anthropic/claude-opus-5") + expect(call.temperature).toBeUndefined() + expect(call.max_completion_tokens).toBe(128000) + }) + it("adds cache breakpoints for supported models", async () => { const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") const handler = new VercelAiGatewayHandler({ 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/anthropic.ts b/src/api/providers/anthropic.ts index f69c6eb29c..b55c8b3089 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -106,6 +106,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa case "claude-opus-4-6": case "claude-opus-4-7": case "claude-opus-4-8": + case "claude-opus-5": case "claude-fable-5": case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": @@ -177,6 +178,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa case "claude-opus-4-6": case "claude-opus-4-7": case "claude-opus-4-8": + case "claude-opus-5": case "claude-fable-5": case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index ae072b3559..0d39e843c4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -10,9 +10,12 @@ import { ToolConfiguration, ToolChoice, } from "@aws-sdk/client-bedrock-runtime" +import { NodeHttpHandler } from "@smithy/node-http-handler" import OpenAI from "openai" import { fromIni } from "@aws-sdk/credential-providers" import { Anthropic } from "@anthropic-ai/sdk" +import { HttpProxyAgent } from "http-proxy-agent" +import { HttpsProxyAgent } from "https-proxy-agent" import { type ModelInfo, @@ -30,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" @@ -44,6 +48,7 @@ import { convertToBedrockConverseMessages as sharedConverter } from "../transfor import { getModelParams } from "../transform/model-params" import { shouldUseReasoningBudget } from "../../shared/api" import { normalizeToolSchema } from "../../utils/json-schema" +import { getSystemProxyUrl } from "../../utils/networkProxy" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" /************************************************************************************ @@ -95,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 @@ -294,6 +299,25 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } + // When a corporate proxy is configured, Node resolves DNS locally before tunneling, + // causing ENOTFOUND for endpoints that only the proxy can reach. HttpProxyAgent and + // HttpsProxyAgent use CONNECT tunneling so the proxy handles DNS resolution instead. + // + // A custom endpoint (e.g. a VPC endpoint) is passed so NO_PROXY can bypass the proxy + // for directly-reachable hosts. For the default managed endpoint we don't reconstruct + // the hostname (the AWS SDK resolves it internally, and it varies by partition), so the + // proxy always applies there. + const proxyUrl = getSystemProxyUrl( + typeof clientConfig.endpoint === "string" ? clientConfig.endpoint : undefined, + ) + if (proxyUrl) { + clientConfig.requestHandler = new NodeHttpHandler({ + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl), + requestTimeout: 0, + }) + } + this.client = new BedrockRuntimeClient(clientConfig) } @@ -301,12 +325,12 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * Detect models that require the adaptive-thinking API contract. * * Starting with Claude Opus 4.7 (and the matching Sonnet 4.7), and continuing - * in Opus 4.8 / Sonnet 4.8, Claude Fable 5, and Claude Sonnet 5, Anthropic - * removed sampling parameters (temperature/top_p/top_k) and replaced + * in Opus 4.8 / Sonnet 4.8, Claude Fable 5, Claude Sonnet 5, and Claude Opus 5, + * Anthropic removed sampling parameters (temperature/top_p/top_k) and replaced * budget_tokens-based thinking with `thinking.type: "adaptive"` plus * `output_config.effort`. The migration guide from 4.7 → 4.8 confirms there - * are no further breaking API changes, and Fable 5 / Sonnet 5 keep the same - * adaptive-thinking contract, so a single guard matches all generations. + * are no further breaking API changes, and Fable 5 / Sonnet 5 / Opus 5 keep the + * same adaptive-thinking contract, so a single guard matches all generations. * Shared by createMessage and completePrompt so both request paths omit * temperature for these models (sending it causes a 400). * @@ -318,6 +342,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH return ( baseModelId.includes("opus-4-7") || baseModelId.includes("opus-4-8") || + baseModelId.includes("opus-5") || baseModelId.includes("fable-5") || baseModelId.includes("sonnet-4-7") || baseModelId.includes("sonnet-4-8") || @@ -529,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/fetchers/__tests__/kimi-code.spec.ts b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts new file mode 100644 index 0000000000..3fbe995752 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts @@ -0,0 +1,100 @@ +import { getKimiCodeModels, mapKimiCodeModel } from "../kimi-code" + +describe("Kimi Code model discovery", () => { + beforeEach(() => vi.restoreAllMocks()) + afterEach(() => vi.useRealTimers()) + + it("maps official model fields", () => { + expect( + mapKimiCodeModel({ + id: "kimi-test", + context_length: 131072, + supports_reasoning: true, + supports_image_in: true, + display_name: "Kimi Test", + }), + ).toMatchObject({ + contextWindow: 131072, + supportsReasoningEffort: ["low", "high", "max"], + requiredReasoningEffort: true, + reasoningEffort: "max", + supportsImages: true, + displayName: "Kimi Test", + }) + }) + + it("uses bearer auth for GET /models", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: "kimi-for-coding", context_length: 262144 }] }), { + status: 200, + }), + ) + const models = await getKimiCodeModels("secret-token") + expect(models).toHaveProperty("kimi-for-coding") + expect(fetch).toHaveBeenCalledWith( + "https://api.kimi.com/coding/v1/models", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer secret-token" }) }), + ) + }) + + it("applies default values when optional fields are missing", () => { + const mapped = mapKimiCodeModel({ + id: "basic-model", + }) + expect(mapped.supportsReasoningEffort).toBe(false) + expect(mapped.requiredReasoningEffort).toBe(false) + expect(mapped.reasoningEffort).toBeUndefined() + expect(mapped.supportsImages).toBe(false) + expect(mapped.contextWindow).toBeGreaterThan(0) + }) + + it("throws error when apiKey is missing", async () => { + await expect(getKimiCodeModels()).rejects.toThrow("Kimi Code authentication is required") + }) + + it("throws error with status code on failed request", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }), + ) + try { + await getKimiCodeModels("bad-token") + expect.fail("should have thrown") + } catch (error: any) { + expect(error.message).toContain("401") + expect(error.status).toBe(401) + } + }) + + it("returns multiple models as a record", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { id: "model-a", context_length: 100000 }, + { id: "model-b", context_length: 200000, supports_reasoning: true }, + ], + }), + { status: 200 }, + ), + ) + const models = await getKimiCodeModels("token") + expect(Object.keys(models)).toHaveLength(2) + expect(models["model-a"].contextWindow).toBe(100000) + expect(models["model-b"].supportsReasoningEffort).toEqual(["low", "high", "max"]) + }) + + it("aborts model discovery after its deadline", async () => { + vi.useFakeTimers() + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + }) + const result = expect(getKimiCodeModels("token")).rejects.toThrow("timed out") + + await vi.advanceTimersByTimeAsync(10_000) + await result + expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts index c05cda8839..8e6d49ddae 100644 --- a/src/api/providers/fetchers/__tests__/litellm.spec.ts +++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts @@ -697,4 +697,117 @@ describe("getLiteLLMModels", () => { description: "model-with-only-max-output-tokens via LiteLLM proxy", }) }) + + describe("preserveReasoning inference", () => { + it("sets preserveReasoning: true when only the routed model (not the alias) matches a known reasoning model id", async () => { + const mockResponse = { + data: { + data: [ + { + model_name: "my-deepseek-alias", + model_info: { + max_tokens: 8192, + max_input_tokens: 128000, + }, + litellm_params: { + model: "deepseek/deepseek-reasoner", + }, + }, + { + model_name: "my-kimi-alias", + model_info: { + max_tokens: 8192, + max_input_tokens: 128000, + }, + litellm_params: { + model: "bedrock/moonshot.kimi-k2-thinking", + }, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["my-deepseek-alias"]).toMatchObject({ preserveReasoning: true }) + expect(result["my-kimi-alias"]).toMatchObject({ preserveReasoning: true }) + }) + + it("omits preserveReasoning when the routed model does not match a known reasoning model id", async () => { + const mockResponse = { + data: { + data: [ + { + model_name: "gpt-4-turbo", + model_info: { + max_tokens: 8192, + max_input_tokens: 128000, + }, + litellm_params: { + model: "openai/gpt-4-turbo", + }, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["gpt-4-turbo"]).not.toHaveProperty("preserveReasoning") + }) + + it("matches against the model alias even when the routed model name does not match", async () => { + const mockResponse = { + data: { + data: [ + { + model_name: "glm-5.2", + model_info: { + max_tokens: 8192, + max_input_tokens: 128000, + }, + litellm_params: { + model: "zai/some-custom-deployment", + }, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["glm-5.2"]).toMatchObject({ preserveReasoning: true }) + }) + + it("does not match a model id that merely contains a known family as a substring", async () => { + const mockResponse = { + data: { + data: [ + { + model_name: "glm-5-flash", + model_info: { + max_tokens: 8192, + max_input_tokens: 128000, + }, + litellm_params: { + model: "zai/glm-5-flash", + }, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["glm-5-flash"]).not.toHaveProperty("preserveReasoning") + }) + }) }) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 4b851d7d1f..169e4a0efa 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -44,6 +44,7 @@ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") vi.mock("../kenari") +vi.mock("../moonshot") // Mock ContextProxy with a simple static instance vi.mock("../../../core/config/ContextProxy", () => ({ @@ -57,7 +58,8 @@ vi.mock("../../../core/config/ContextProxy", () => ({ })) // Then imports -import type { Mock } from "vitest" +import type { Mock, Mocked } from "vitest" +import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" import NodeCache from "node-cache" import { getModels, getModelsFromCache } from "../modelCache" @@ -65,11 +67,13 @@ import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" import { getKenariModels } from "../kenari" +import { getMoonshotModels } from "../moonshot" const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock const mockGetKenariModels = getKenariModels as Mock +const mockGetMoonshotModels = getMoonshotModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -90,7 +94,7 @@ describe("getModels with new GetModelsOptions", () => { mockGetLiteLLMModels.mockResolvedValue(mockModels) const result = await getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }) @@ -110,7 +114,24 @@ describe("getModels with new GetModelsOptions", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) + + expect(mockGetOpenRouterModels).toHaveBeenCalled() + expect(result).toEqual(mockModels) + }) + + it("dispatches OpenRouter through its canonical provider identifier", async () => { + const mockModels = { + "openrouter/canonical-model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + mockGetOpenRouterModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(mockGetOpenRouterModels).toHaveBeenCalled() expect(result).toEqual(mockModels) @@ -127,12 +148,33 @@ describe("getModels with new GetModelsOptions", () => { } mockGetRequestyModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY }) + const result = await getModels({ provider: providerIdentifiers.requesty, apiKey: DUMMY_REQUESTY_KEY }) expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY) expect(result).toEqual(mockModels) }) + it("dispatches credentialed fetchers through canonical provider identifiers", async () => { + const mockModels = { + "requesty/canonical-model": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + }, + } + + mockGetRequestyModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.requesty, + apiKey: DUMMY_REQUESTY_KEY, + baseUrl: "https://router.requesty.ai/v1", + }) + + expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY) + expect(result).toEqual(mockModels) + }) + it("calls getKenariModels with optional API key", async () => { const mockModels = { "glm-5-2": { @@ -144,7 +186,7 @@ describe("getModels with new GetModelsOptions", () => { } mockGetKenariModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "kenari", apiKey: "kenari-key-for-testing" }) + const result = await getModels({ provider: providerIdentifiers.kenari, apiKey: "kenari-key-for-testing" }) expect(mockGetKenariModels).toHaveBeenCalledWith("kenari-key-for-testing") expect(result).toEqual(mockModels) @@ -156,17 +198,38 @@ describe("getModels with new GetModelsOptions", () => { await expect( getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }), ).rejects.toThrow("LiteLLM connection failed") }) + it("calls getMoonshotModels with correct parameters", async () => { + const mockModels = { + "kimi-k2-0905-preview": { + maxTokens: 16384, + contextWindow: 262144, + supportsPromptCache: true, + description: "Moonshot Kimi K2", + }, + } + mockGetMoonshotModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.moonshot, + apiKey: "test-key", + baseUrl: "https://api.moonshot.ai/v1", + }) + + expect(mockGetMoonshotModels).toHaveBeenCalledWith("https://api.moonshot.ai/v1", "test-key") + expect(result).toEqual(mockModels) + }) + it("validates exhaustive provider checking with unknown provider", async () => { // This test ensures TypeScript catches unknown providers at compile time // In practice, the discriminated union should prevent this at compile time - const unknownProvider = "unknown" as any + const unknownProvider = "unknown" as typeof providerIdentifiers.openrouter await expect( getModels({ @@ -177,13 +240,13 @@ describe("getModels with new GetModelsOptions", () => { }) describe("getModelsFromCache disk fallback", () => { - let mockCache: any + let mockCache: Mocked beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) // Reset memory cache to always miss mockCache.get.mockReturnValue(undefined) // Reset fs mocks @@ -194,7 +257,7 @@ describe("getModelsFromCache disk fallback", () => { it("returns undefined when both memory and disk cache miss", () => { vi.mocked(fsSync.existsSync).mockReturnValue(false) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() }) @@ -210,13 +273,30 @@ describe("getModelsFromCache disk fallback", () => { mockCache.get.mockReturnValue(memoryModels) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toEqual(memoryModels) // Disk should not be checked when memory cache hits expect(fsSync.existsSync).not.toHaveBeenCalled() }) + it("isolates authenticated users through the canonical Zoo Gateway identifier", () => { + const previousUserModels = { + "previous-user/model": { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + mockCache.get.mockReturnValue(previousUserModels) + + const result = getModelsFromCache(providerIdentifiers.zooGateway) + + expect(result).toBeUndefined() + expect(mockCache.get).not.toHaveBeenCalled() + }) + it("returns disk cache data when memory cache misses and context is available", () => { // Note: This test validates the logic but the ContextProxy mock in test environment // returns undefined for getCacheDirectoryPathSync, which is expected behavior @@ -233,7 +313,7 @@ describe("getModelsFromCache disk fallback", () => { vi.mocked(fsSync.existsSync).mockReturnValue(true) vi.mocked(fsSync.readFileSync).mockReturnValue(JSON.stringify(diskModels)) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) // In the test environment, ContextProxy.instance may not be fully initialized, // so getCacheDirectoryPathSync returns undefined and disk cache is not attempted @@ -248,7 +328,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -262,7 +342,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -272,15 +352,15 @@ describe("getModelsFromCache disk fallback", () => { }) describe("empty cache protection", () => { - let mockCache: any - let mockGet: Mock - let mockSet: Mock + let mockCache: Mocked + let mockGet: Mocked["get"] + let mockSet: Mocked["set"] beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockGet = mockCache.get mockSet = mockCache.set // Reset memory cache to always miss by default @@ -292,7 +372,7 @@ describe("empty cache protection", () => { // API returns empty object (simulating failure) mockGetOpenRouterModels.mockResolvedValue({}) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -310,7 +390,7 @@ describe("empty cache protection", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(result).toEqual(mockModels) expect(mockSet).toHaveBeenCalledWith("openrouter", mockModels) @@ -334,7 +414,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache, not empty expect(result).toEqual(existingModels) @@ -364,7 +444,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue(newModels) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return new models expect(result).toEqual(newModels) @@ -386,7 +466,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache on error expect(result).toEqual(existingModels) @@ -397,7 +477,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty when no cache and API fails expect(result).toEqual({}) @@ -410,7 +490,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -438,8 +518,8 @@ describe("empty cache protection", () => { const { refreshModels } = await import("../modelCache") // Start two concurrent refresh calls - const promise1 = refreshModels({ provider: "openrouter" }) - const promise2 = refreshModels({ provider: "openrouter" }) + const promise1 = refreshModels({ provider: providerIdentifiers.openrouter }) + const promise2 = refreshModels({ provider: providerIdentifiers.openrouter }) // API should only be called once (second call reuses in-flight request) expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(1) @@ -471,8 +551,8 @@ describe("empty cache protection", () => { // Different keys -> separate compound keys -> two distinct fetches. const [a, b] = await Promise.all([ - refreshModels({ provider: "requesty", apiKey: "key-one" }), - refreshModels({ provider: "requesty", apiKey: "key-two" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-one" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-two" }), ]) expect(mockGetRequestyModels).toHaveBeenCalledTimes(2) expect(a).toEqual(mockModels) @@ -488,8 +568,8 @@ describe("empty cache protection", () => { }), ) - const shared1 = refreshModels({ provider: "requesty", apiKey: "same-key" }) - const shared2 = refreshModels({ provider: "requesty", apiKey: "same-key" }) + const shared1 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) + const shared2 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) expect(mockGetRequestyModels).toHaveBeenCalledTimes(1) @@ -505,10 +585,10 @@ describe("key-scoped cache key derivation", () => { // Exercises the per-API-key cache discriminator that all KEY_SCOPED_PROVIDERS share. // Requesty is used only because it is a key-scoped provider with a mocked fetcher; the // behavior under test is provider-agnostic. - const keyScopedProvider = "requesty" as const + const keyScopedProvider = providerIdentifiers.requesty - let mockCache: any - let mockSet: Mock + let mockCache: Mocked + let mockSet: Mocked["set"] const mockModels = { "key-scoped/model": { @@ -522,7 +602,7 @@ describe("key-scoped cache key derivation", () => { beforeEach(() => { vi.clearAllMocks() const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockCache.get.mockReturnValue(undefined) mockSet = mockCache.set mockGetRequestyModels.mockResolvedValue(mockModels) @@ -603,7 +683,11 @@ describe("compound cache key derivation across scoping dimensions", () => { } it("includes both the server URL and the key discriminator for url+key-scoped providers", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const cacheKey = writtenCacheKey() // Expected shape: provider:url:keyDiscriminator @@ -611,18 +695,26 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("normalizes trailing slashes in the server URL so equivalent URLs share a cache key", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000/" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000/", + }) const withSlash = writtenCacheKey() mockSet.mockClear() - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const withoutSlash = writtenCacheKey() expect(withSlash).toEqual(withoutSlash) }) it("includes only the server URL when a url-scoped provider has no API key", async () => { - await getModels({ provider: "litellm", baseUrl: "http://host:4000" }) + await getModels({ provider: providerIdentifiers.litellm, baseUrl: "http://host:4000" }) const cacheKey = writtenCacheKey() // No trailing key discriminator when apiKey is absent. @@ -630,7 +722,11 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => { - await getModels({ provider: "openrouter", apiKey: "ignored-key", baseUrl: "http://ignored:4000" }) + await getModels({ + provider: providerIdentifiers.openrouter, + apiKey: "ignored-key", + baseUrl: "http://ignored:4000", + }) const cacheKey = writtenCacheKey() expect(cacheKey).toBe("openrouter") diff --git a/src/api/providers/fetchers/__tests__/moonshot.spec.ts b/src/api/providers/fetchers/__tests__/moonshot.spec.ts new file mode 100644 index 0000000000..786c6dfbdf --- /dev/null +++ b/src/api/providers/fetchers/__tests__/moonshot.spec.ts @@ -0,0 +1,180 @@ +import { moonshotModels } from "@roo-code/types" + +import { getMoonshotModels } from "../moonshot" + +describe("getMoonshotModels", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it("merges API response with static model specs for known models", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "kimi-k2-0905-preview" }, { id: "kimi-k2-thinking" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.ai/v1/models", expect.any(Object)) + expect(models["kimi-k2-0905-preview"]).toEqual(moonshotModels["kimi-k2-0905-preview"]) + expect(models["kimi-k2-thinking"]).toEqual(moonshotModels["kimi-k2-thinking"]) + }) + + it("provides sane defaults for unknown model IDs without pricing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "unknown-model-id" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(models["unknown-model-id"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + description: "Moonshot model: unknown-model-id", + }) + }) + + it("throws for HTTP errors", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) as unknown as typeof fetch + + await expect(getMoonshotModels("https://api.moonshot.ai/v1", "invalid-key")).rejects.toThrow( + "HTTP 401: Unauthorized", + ) + }) + + it("uses default base URL when none provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels(undefined, "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.ai/v1/models", expect.any(Object)) + }) + + it("keeps /v1 in base URL and strips trailing slash", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels("https://api.moonshot.cn/v1/", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.cn/v1/models", expect.any(Object)) + }) + + it("throws when response data is not an array", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: "not-an-array" }), + }) as unknown as typeof fetch + + await expect(getMoonshotModels("https://api.moonshot.ai/v1", "mock-key")).rejects.toThrow( + "Unexpected response format", + ) + }) + + it("throws when response data is missing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + }) as unknown as typeof fetch + + await expect(getMoonshotModels("https://api.moonshot.ai/v1", "mock-key")).rejects.toThrow( + "Unexpected response format", + ) + }) + + it("skips models with empty or non-string ID", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "" }, { id: 123 }, { id: null }, { id: "kimi-k2-0905-preview" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(1) + expect(models["kimi-k2-0905-preview"]).toBeDefined() + }) + + it("includes Authorization header when apiKey provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels("https://api.moonshot.ai/v1", "my-secret-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://api.moonshot.ai/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-secret-key", + }), + }), + ) + }) + + it("does not include Authorization header when no apiKey", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels("https://api.moonshot.ai/v1", undefined) + + const callArgs = (globalThis.fetch as any).mock.calls[0][1].headers + expect(callArgs["Authorization"]).toBeUndefined() + }) + + it("mixes known and unknown models in same response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "kimi-k2-0905-preview" }, { id: "some-new-model" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(models["kimi-k2-0905-preview"]).toEqual(moonshotModels["kimi-k2-0905-preview"]) + expect(models["some-new-model"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + description: "Moonshot model: some-new-model", + }) + }) + + it("handles HTTP error with unreadable body gracefully", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: vi.fn().mockRejectedValue(new Error("network error")), + }) as unknown as typeof fetch + + await expect(getMoonshotModels("https://api.moonshot.ai/v1", "mock-key")).rejects.toThrow( + "HTTP 500: Internal Server Error", + ) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 7794031fa5..89169c9c56 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -346,6 +346,34 @@ describe("OpenRouter API", () => { expect(result.supportsReasoningBinary).toBe(true) }) + it("sets claude-opus-5 model to Anthropic max tokens and omits temperature", () => { + const mockModel = { + name: "Claude Opus 5", + description: "Test model", + context_length: 1000000, + max_completion_tokens: 128000, + pricing: { + prompt: "0.000005", + completion: "0.000025", + }, + } + + const result = parseOpenRouterModel({ + id: "anthropic/claude-opus-5", + model: mockModel, + inputModality: ["text", "image"], + outputModality: ["text"], + maxTokens: 128000, + supportedParameters: ["reasoning", "include_reasoning"], + }) + + expect(result.maxTokens).toBe(128000) + expect(result.contextWindow).toBe(1000000) + expect(result.supportsTemperature).toBe(false) + expect(result.supportsReasoningBudget).toBe(true) + expect(result.supportsReasoningBinary).toBe(true) + }) + it("sets horizon-alpha model to 32k max tokens", () => { const mockModel = { name: "Horizon Alpha", diff --git a/src/api/providers/fetchers/__tests__/requesty.spec.ts b/src/api/providers/fetchers/__tests__/requesty.spec.ts index 89c56063ef..25d622b6ed 100644 --- a/src/api/providers/fetchers/__tests__/requesty.spec.ts +++ b/src/api/providers/fetchers/__tests__/requesty.spec.ts @@ -75,6 +75,31 @@ describe("getRequestyModels", () => { expect(sonnet5.supportsTemperature).toBe(false) }) + it("applies Opus 5 overrides when parsing anthropic/claude-opus-5", async () => { + const rawOpus5 = makeRawModel({ + id: "anthropic/claude-opus-5", + max_output_tokens: 128000, + context_window: 1000000, + supports_caching: true, + supports_vision: true, + supports_reasoning: true, + input_price: "0.000005", + output_price: "0.000025", + caching_price: "0.00000625", + cached_price: "0.0000005", + }) + + mockAxiosGet.mockResolvedValueOnce({ data: { data: [rawOpus5] } }) + + const models = await getRequestyModels() + const opus5 = models["anthropic/claude-opus-5"] + + expect(opus5).toBeDefined() + expect(opus5.supportsReasoningBudget).toBe(true) + expect(opus5.supportsReasoningBinary).toBe(true) + expect(opus5.supportsTemperature).toBe(false) + }) + it("does not apply Fable 5 overrides to other models", async () => { const rawSonnet = makeRawModel({ id: "anthropic/claude-sonnet-4.6", diff --git a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts index 28e08d776b..fa8e89e8d0 100644 --- a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts @@ -237,6 +237,22 @@ describe("Vercel AI Gateway Fetchers", () => { expect(result.supportsTemperature).toBe(false) }) + it("marks Claude Opus 5 as not supporting temperature", () => { + const result = parseVercelAiGatewayModel({ + id: "anthropic/claude-opus-5", + model: { + ...baseModel, + id: "anthropic/claude-opus-5", + context_window: 1000000, + max_tokens: 128000, + }, + }) + + expect(result.maxTokens).toBe(128000) + expect(result.contextWindow).toBe(1000000) + expect(result.supportsTemperature).toBe(false) + }) + it("detects vision-only models", () => { // claude 3.5 haiku in VERCEL_AI_GATEWAY_VISION_ONLY_MODELS const visionModel = { diff --git a/src/api/providers/fetchers/kimi-code.ts b/src/api/providers/fetchers/kimi-code.ts new file mode 100644 index 0000000000..10b93e26cf --- /dev/null +++ b/src/api/providers/fetchers/kimi-code.ts @@ -0,0 +1,58 @@ +import { z } from "zod" + +import { + KIMI_CODE_BASE_URL, + kimiCodeDefaultModelInfo, + kimiCodeReasoningEfforts, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" + +const kimiCodeModelSchema = z.object({ + id: z.string().min(1), + context_length: z.number().positive().optional(), + supports_reasoning: z.boolean().optional(), + supports_image_in: z.boolean().optional(), + display_name: z.string().optional(), +}) + +const kimiCodeModelsResponseSchema = z.object({ data: z.array(kimiCodeModelSchema) }) + +const KIMI_CODE_MODELS_TIMEOUT_MS = 10_000 + +export function mapKimiCodeModel(model: z.infer): ModelInfo { + const supportsReasoning = model.supports_reasoning ?? false + return { + ...kimiCodeDefaultModelInfo, + contextWindow: model.context_length ?? kimiCodeDefaultModelInfo.contextWindow, + supportsReasoningEffort: supportsReasoning ? [...kimiCodeReasoningEfforts] : false, + requiredReasoningEffort: supportsReasoning, + reasoningEffort: supportsReasoning ? "max" : undefined, + supportsImages: model.supports_image_in ?? false, + displayName: model.display_name, + } +} + +export async function getKimiCodeModels(apiKey?: string): Promise { + if (!apiKey) throw new Error("Kimi Code authentication is required to fetch models") + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(new Error("Kimi Code models request timed out")), + KIMI_CODE_MODELS_TIMEOUT_MS, + ) + try { + const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }) + if (!response.ok) { + const error = new Error(`Kimi Code models request failed: ${response.status} ${response.statusText}`) + ;(error as Error & { status?: number }).status = response.status + throw error + } + const parsed = kimiCodeModelsResponseSchema.parse(await response.json()) + return Object.fromEntries(parsed.data.map((model) => [model.id, mapKimiCodeModel(model)])) + } finally { + clearTimeout(timeout) + } +} diff --git a/src/api/providers/fetchers/litellm.ts b/src/api/providers/fetchers/litellm.ts index de895d01fb..83373e5163 100644 --- a/src/api/providers/fetchers/litellm.ts +++ b/src/api/providers/fetchers/litellm.ts @@ -1,6 +1,7 @@ import axios from "axios" import type { ModelRecord } from "@roo-code/types" +import { isLiteLLMPreserveReasoningModel } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" /** @@ -40,6 +41,11 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise if (!modelName || !modelInfo || !litellmModelName) continue + // LiteLLM's /v1/model/info never reports reasoning capability flags, so infer + // preserveReasoning from explicit model ids in either the alias or routed model name. + const preservesReasoning = + isLiteLLMPreserveReasoningModel(modelName) || isLiteLLMPreserveReasoningModel(litellmModelName) + models[modelName] = { maxTokens: modelInfo.max_output_tokens || modelInfo.max_tokens || 8192, contextWindow: modelInfo.max_input_tokens || 200000, @@ -55,6 +61,7 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise cacheReadsPrice: modelInfo.cache_read_input_token_cost ? modelInfo.cache_read_input_token_cost * 1000000 : undefined, + ...(preservesReasoning && { preserveReasoning: true }), description: `${modelName} via LiteLLM proxy`, } } diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index deca6a7c00..af956548ef 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -7,7 +7,7 @@ import NodeCache from "node-cache" import { z } from "zod" import type { ProviderName, ModelRecord } from "@roo-code/types" -import { modelInfoSchema, TelemetryEventName } from "@roo-code/types" +import { modelInfoSchema, providerIdentifiers, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -29,7 +29,9 @@ import { getOllamaModels } from "./ollama" import { getLMStudioModels } from "./lmstudio" import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" +import { getMoonshotModels } from "./moonshot" import { getZooGatewayModels } from "./zoo-gateway" +import { getKimiCodeModels } from "./kimi-code" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -41,31 +43,36 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) // deduplicate each other's in-flight refreshes. const inFlightRefresh = new Map>() -// Providers whose model lists are scoped to the signed-in user (e.g. per-account -// allowlists or org policies). For these we MUST NOT cache results on disk or -// in memory: a sign-in/out cycle could otherwise serve a previous user's model -// list to the next user, and stale data could mask backend allowlist updates. -const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set(["zoo-gateway"]) - // Providers whose model list is determined by the server URL, not just by the provider name. // Each unique baseUrl must be cached independently so that switching endpoints never serves // stale results from a previously-cached server. const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", - "poe", - "deepseek", - "ollama", - "lmstudio", - "requesty", + providerIdentifiers.litellm, + providerIdentifiers.poe, + providerIdentifiers.deepseek, + providerIdentifiers.moonshot, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.requesty, ]) // Providers where the API key itself determines which models are visible (e.g. per-key // allowlists). For these the cache key also includes a short hash of // the API key so that two different keys on the same server never share a cache entry. const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", // Per-key model allowlists are a first-class LiteLLM proxy feature - "poe", // Per-account model availability - "requesty", // Per-account custom model policies + providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature + providerIdentifiers.poe, // Per-account model availability + providerIdentifiers.requesty, // Per-account custom model policies + providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) +]) + +// Providers whose model lists are scoped to the signed-in user (e.g. per-account +// allowlists or org policies). For these we MUST NOT cache results on disk or +// in memory: a sign-in/out cycle could otherwise serve a previous user's model +// list to the next user, and stale data could mask backend allowlist updates. +const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set([ + providerIdentifiers.zooGateway, + providerIdentifiers.kimiCode, ]) function isAuthScopedProvider(provider: RouterName): boolean { @@ -187,43 +194,49 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { setTimeout(async () => { // Providers that work without API keys const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [ - { provider: "openrouter", options: { provider: "openrouter" } }, - { provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { + provider: providerIdentifiers.openrouter, + options: { provider: providerIdentifiers.openrouter }, + }, + { + provider: providerIdentifiers.vercelAiGateway, + options: { provider: providerIdentifiers.vercelAiGateway }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/fetchers/moonshot.ts b/src/api/providers/fetchers/moonshot.ts new file mode 100644 index 0000000000..48efa03950 --- /dev/null +++ b/src/api/providers/fetchers/moonshot.ts @@ -0,0 +1,89 @@ +import type { ModelRecord } from "@roo-code/types" +import { moonshotModels } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" + +/** + * Fetches available models from the Moonshot API and merges them with known specs. + * + * The Moonshot /models endpoint only returns basic model IDs without pricing + * or context window info, so we merge the API response with the static + * `moonshotModels` map for known models. Unknown models get sensible defaults. + */ +export async function getMoonshotModels(baseUrl?: string, apiKey?: string): Promise { + // Moonshot API uses OpenAI-compatible /v1/models endpoint. + // The base URL from settings already includes /v1 (e.g. https://api.moonshot.ai/v1), + // so we keep it as-is and append /models directly. + const base = (baseUrl || "https://api.moonshot.ai/v1").replace(/\/+$/, "") + const url = `${base}/models` + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }) + + if (!response.ok) { + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getMoonshotModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getMoonshotModels] Unexpected response format:", data) + throw new Error("Failed to fetch Moonshot models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + + for (const model of data.data) { + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId) continue + + const knownSpecs = moonshotModels[modelId as keyof typeof moonshotModels] + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + models[modelId] = { + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + description: `Moonshot model: ${modelId}`, + } + } + } + + return models + } finally { + clearTimeout(timeoutId) + } +} diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 2dcb342445..a25c1b96fa 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -277,6 +277,13 @@ export const parseOpenRouterModel = ({ modelInfo.supportsTemperature = false } + // Set claude-opus-5 model to use the correct Anthropic configuration + if (id === "anthropic/claude-opus-5") { + modelInfo.maxTokens = anthropicModels["claude-opus-5"].maxTokens + modelInfo.supportsReasoningBinary = true + modelInfo.supportsTemperature = false + } + // Ensure correct reasoning handling for Claude Haiku 4.5 on OpenRouter // Use budget control and disable effort-based reasoning fallback if (id === "anthropic/claude-haiku-4.5") { diff --git a/src/api/providers/fetchers/requesty.ts b/src/api/providers/fetchers/requesty.ts index 2260d02978..861fbfb181 100644 --- a/src/api/providers/fetchers/requesty.ts +++ b/src/api/providers/fetchers/requesty.ts @@ -57,6 +57,12 @@ export async function getRequestyModels(baseUrl?: string, apiKey?: string): Prom modelInfo.supportsTemperature = false } + if (rawModel.id === "anthropic/claude-opus-5") { + modelInfo.supportsReasoningBudget = true + modelInfo.supportsReasoningBinary = true + modelInfo.supportsTemperature = false + } + models[rawModel.id] = modelInfo } } catch (error) { diff --git a/src/api/providers/fetchers/vercel-ai-gateway.ts b/src/api/providers/fetchers/vercel-ai-gateway.ts index 1c4a4279e1..41865ebf44 100644 --- a/src/api/providers/fetchers/vercel-ai-gateway.ts +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -124,5 +124,9 @@ export const parseVercelAiGatewayModel = ({ id, model }: { id: string; model: Ve modelInfo.supportsTemperature = false } + if (id === "anthropic/claude-opus-5") { + modelInfo.supportsTemperature = false + } + return modelInfo } 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/index.ts b/src/api/providers/index.ts index 74f49c8b41..5bdd7c8deb 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -3,6 +3,7 @@ export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" export { DeepSeekHandler } from "./deepseek" export { MoonshotHandler } from "./moonshot" +export { KimiCodeHandler } from "./kimi-code" export { FakeAIHandler } from "./fake-ai" export { GeminiHandler } from "./gemini" export { LiteLLMHandler } from "./lite-llm" diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts new file mode 100644 index 0000000000..0a50ce6ec3 --- /dev/null +++ b/src/api/providers/kimi-code.ts @@ -0,0 +1,118 @@ +import type { Anthropic } from "@anthropic-ai/sdk" + +import { + KIMI_CODE_BASE_URL, + kimiCodeDefaultModelId, + kimiCodeDefaultModelInfo, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" +import { kimiCodeOAuthManager } from "../../integrations/kimi-code/oauth" + +import type { ApiHandlerCreateMessageMetadata } from "../index" +import type { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { OpenAiHandler } from "./openai" +import { getModels } from "./fetchers/modelCache" + +function getHttpStatus(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined + const candidate = error as { status?: unknown; cause?: { status?: unknown } } + return typeof candidate.status === "number" + ? candidate.status + : typeof candidate.cause?.status === "number" + ? candidate.cause.status + : undefined +} + +export class KimiCodeHandler extends OpenAiHandler { + private readonly kimiOptions: ApiHandlerOptions + private models: ModelRecord = {} + private modelDiscoveryAttempted = false + + constructor(options: ApiHandlerOptions) { + super({ + ...options, + openAiBaseUrl: KIMI_CODE_BASE_URL, + openAiApiKey: options.kimiCodeApiKey ?? "not-provided", + openAiModelId: options.apiModelId ?? kimiCodeDefaultModelId, + openAiStreamingEnabled: true, + }) + this.kimiOptions = options + } + + private async resolveAccessToken(forceRefresh = false): Promise { + if ((this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "api-key") { + if (!this.kimiOptions.kimiCodeApiKey) throw new Error("Kimi Code API key is required") + return this.kimiOptions.kimiCodeApiKey + } + + const token = forceRefresh + ? await kimiCodeOAuthManager.forceRefreshAccessToken() + : await kimiCodeOAuthManager.getAccessToken() + if (!token) throw new Error("Not authenticated with Kimi Code. Sign in from provider settings.") + return token + } + + private async prepareRequest(forceRefresh = false): Promise { + const accessToken = await this.resolveAccessToken(forceRefresh) + this.client.apiKey = accessToken + if (!this.modelDiscoveryAttempted) { + this.modelDiscoveryAttempted = true + try { + this.models = await getModels({ provider: "kimi-code", apiKey: accessToken }) + } catch (error) { + // Model discovery is best-effort; preserve the configured ID and fallback metadata. + console.debug("[KimiCode] Model discovery failed; using fallback model metadata", { + message: error instanceof Error ? error.message : String(error), + }) + } + } + } + + private canRefreshOAuth(): boolean { + return (this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "oauth" + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + await this.prepareRequest() + try { + yield* super.createMessage(systemPrompt, messages, metadata) + } catch (error) { + if (getHttpStatus(error) !== 401 || !this.canRefreshOAuth()) throw error + await this.prepareRequest(true) + yield* super.createMessage(systemPrompt, messages, metadata) + } + } + + override async completePrompt(prompt: string): Promise { + await this.prepareRequest() + try { + return await super.completePrompt(prompt) + } catch (error) { + if (getHttpStatus(error) !== 401 || !this.canRefreshOAuth()) throw error + await this.prepareRequest(true) + return super.completePrompt(prompt) + } + } + + override getModel() { + const id = this.kimiOptions.apiModelId || kimiCodeDefaultModelId + const info: ModelInfo = this.models[id] ?? kimiCodeDefaultModelInfo + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.kimiOptions, + defaultTemperature: 0, + }) + return { id, info, ...params } + } +} diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 49cc1b115b..74a610b073 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -9,6 +9,7 @@ import { ApiHandlerOptions } from "../../shared/api" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToR1Format } from "../transform/r1-format" import { GEMINI_THOUGHT_SIGNATURE_BYPASS } from "../transform/gemini-format" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -117,9 +118,19 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa ): ApiStream { const { id: modelId, info } = await this.fetchModel() - const openAiMessages = convertToOpenAiMessages(messages, { - normalizeToolCallId: sanitizeOpenAiCallId, - }) + // Models that require reasoning_content to be echoed back during tool-call + // continuations (see LITELLM_PRESERVE_REASONING_MODEL_IDS) need convertToR1Format: + // it merges consecutive same-role messages and, via mergeToolResultText, folds + // text following tool_results into the last tool message so a user message + // never gets inserted mid-turn and causes the model to drop prior reasoning_content. + const openAiMessages = info.preserveReasoning + ? convertToR1Format(messages, { + normalizeToolCallId: sanitizeOpenAiCallId, + mergeToolResultText: true, + }) + : convertToOpenAiMessages(messages, { + normalizeToolCallId: sanitizeOpenAiCallId, + }) // Prepare messages with cache control if enabled and supported let systemMessage: OpenAI.Chat.ChatCompletionMessageParam 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/moonshot.ts b/src/api/providers/moonshot.ts index 3e90e48f7a..42bd2bfaf7 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -1,3 +1,5 @@ +import OpenAI from "openai" + import { moonshotModels, moonshotDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -5,30 +7,47 @@ import type { ApiHandlerOptions } from "../../shared/api" import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" +import { OpenAiHandler } from "./openai" -export class MoonshotHandler extends OpenAICompatibleHandler { +export class MoonshotHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { - const modelId = options.apiModelId ?? moonshotDefaultModelId - const modelInfo = - moonshotModels[modelId as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] + // Map Moonshot-specific options to the OpenAI-compatible options that + // OpenAiHandler expects. This makes Moonshot use the same battle-tested + // OpenAI Node SDK path as the generic "OpenAI Compatible" provider. + super({ + ...options, + openAiApiKey: options.moonshotApiKey ?? "not-provided", + openAiModelId: options.apiModelId ?? moonshotDefaultModelId, + openAiBaseUrl: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", + }) + } - const config: OpenAICompatibleConfig = { - providerName: "moonshot", - baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", - apiKey: options.moonshotApiKey ?? "not-provided", - modelId, - modelInfo, - modelMaxTokens: options.modelMaxTokens ?? undefined, - temperature: options.modelTemperature ?? undefined, + /** + * Resolve the ModelInfo for a given Moonshot model ID. + * Unknown IDs (e.g. dynamically fetched future models) keep the configured ID + * but fall back to the default model's structural metadata with pricing stripped + * so cost reporting shows "unknown" instead of charging the default model's rates. + */ + private static resolveModelInfo(modelId: string): ModelInfo { + const knownInfo = moonshotModels[modelId as keyof typeof moonshotModels] + if (knownInfo) { + return knownInfo } - super(options, config) + const defaultInfo = moonshotModels[moonshotDefaultModelId] + return { + ...defaultInfo, + maxTokens: undefined, + inputPrice: undefined, + outputPrice: undefined, + cacheReadsPrice: undefined, + cacheWritesPrice: undefined, + } } override getModel() { - const id = this.options.apiModelId ?? moonshotDefaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] + const id = this.options.openAiModelId ?? moonshotDefaultModelId + const info = MoonshotHandler.resolveModelInfo(id) const params = getModelParams({ format: "openai", modelId: id, @@ -43,24 +62,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { * Override to handle Moonshot's usage metrics, including caching. * Moonshot returns cached_tokens in a different location than standard OpenAI. */ - protected override processUsageMetrics(usage: { - inputTokens?: number - outputTokens?: number - details?: { - cachedInputTokens?: number - reasoningTokens?: number - } - raw?: Record - }): ApiStreamUsageChunk { - // Moonshot uses cached_tokens at the top level of raw usage data - const rawUsage = usage.raw as { cached_tokens?: number } | undefined - + protected override processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk { return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens: usage?.prompt_tokens || 0, + outputTokens: usage?.completion_tokens || 0, cacheWriteTokens: 0, - cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, + cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens ?? usage?.cached_tokens, } } @@ -68,9 +76,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { * Override to always include max_tokens for Moonshot (not max_completion_tokens). * Moonshot requires max_tokens parameter to be sent. */ - protected override getMaxOutputTokens(): number | undefined { - const modelInfo = this.config.modelInfo - // Moonshot always requires max_tokens - return this.options.modelMaxTokens || modelInfo.maxTokens || undefined + protected override addMaxTokensIfNeeded( + requestOptions: + | OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming + | OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + modelInfo: ModelInfo, + ): void { + // Moonshot always requires max_tokens (not max_completion_tokens) + requestOptions.max_tokens = this.options.modelMaxTokens || modelInfo.maxTokens || undefined } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 374a715b34..e9bc3bbf5d 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,11 +32,80 @@ import { t } from "../../i18n" export type OpenAiCodexModel = ReturnType +type OpenAiCodexRequestServiceTier = typeof OpenAiCodexServiceTier.Priority + /** * OpenAI Codex base URL for API requests * Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex */ 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) + } + + if (!value || typeof value !== "object") { + return value + } + + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => value.type !== "input_image" || key !== "detail") + .map(([key, child]) => [key, stripInputImageDetail(child)]), + ) +} + +export function transformLunaResponsesLiteBody(requestBody: any, effectiveSessionId: string): any { + if (!Array.isArray(requestBody.input)) { + throw new Error("Invalid gpt-5.6-luna Responses Lite request: input must be an array.") + } + if (requestBody.tools !== undefined && !Array.isArray(requestBody.tools)) { + throw new Error("Invalid gpt-5.6-luna Responses Lite request: tools must be an array when provided.") + } + if (requestBody.instructions !== undefined && typeof requestBody.instructions !== "string") { + throw new Error("Invalid gpt-5.6-luna Responses Lite request: instructions must be a string when provided.") + } + + const { tools, instructions, ...rest } = requestBody + const transformedInput = stripInputImageDetail(requestBody.input) + const reasoning = + requestBody.reasoning && typeof requestBody.reasoning === "object" && !Array.isArray(requestBody.reasoning) + ? requestBody.reasoning + : {} + + return { + ...rest, + input: [ + { type: "additional_tools", role: "developer", tools: tools ?? [] }, + ...(typeof instructions === "string" && instructions.length > 0 + ? [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: instructions }], + }, + ] + : []), + ...transformedInput, + ], + // Luna Responses Lite requires these exact values, so they intentionally + // override any caller-supplied tool_choice or parallel_tool_calls. + tool_choice: "auto", + parallel_tool_calls: false, + prompt_cache_key: effectiveSessionId, + // Luna Responses Lite requires reasoning context "all_turns"; this intentionally + // overwrites any context value already present in the incoming reasoning config. + reasoning: { ...reasoning, context: "all_turns" }, + } +} /** * OpenAiCodexHandler - Uses OpenAI Responses API with OAuth authentication @@ -183,12 +255,26 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Build request body // Per the implementation guide: Codex backend may reject some parameters // Notably: max_output_tokens and prompt_cache_retention may be rejected - const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) + const effectiveSessionId = metadata?.taskId || this.sessionId + const baseRequestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) + let requestBody: any + try { + requestBody = + model.id === LUNA_MODEL_ID + ? this.buildLunaRequestBody(baseRequestBody, effectiveSessionId) + : baseRequestBody + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(message, this.providerName, model.id, "createMessage"), + ) + throw error + } // Make the request with retry on auth failure for (let attempt = 0; attempt < 2; attempt++) { try { - yield* this.executeRequest(requestBody, model, accessToken, metadata?.taskId) + yield* this.executeRequest(requestBody, model, accessToken, effectiveSessionId) return } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -213,6 +299,10 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } } + private buildLunaRequestBody(baseRequestBody: any, effectiveSessionId: string): any { + return transformLunaResponsesLiteBody(baseRequestBody, effectiveSessionId) + } + private buildRequestBody( model: OpenAiCodexModel, formattedInput: any, @@ -285,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 @@ -303,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 @@ -344,7 +437,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion requestBody: any, model: OpenAiCodexModel, accessToken: string, - taskId?: string, + effectiveSessionId: string, ): ApiStream { // Create AbortController for cancellation this.abortController = new AbortController() @@ -357,12 +450,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const accountId = await openAiCodexOAuthManager.getAccountId() // Build Codex-specific headers. Authorization is provided by the SDK apiKey. - const codexHeaders: Record = { - originator: "zoo-code", - session_id: taskId || this.sessionId, - "User-Agent": `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), - } + const codexHeaders = this.buildCodexHeaders(model, effectiveSessionId, accountId) // Allow tests to inject a client. If none is injected, create one for this request. const client = @@ -400,7 +488,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } } catch (_sdkErr) { // Fallback to manual SSE via fetch (Codex backend). - yield* this.makeCodexRequest(requestBody, model, accessToken, taskId) + yield* this.makeCodexRequest(requestBody, model, accessToken, effectiveSessionId) } } finally { this.abortController = undefined @@ -494,7 +582,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion requestBody: any, model: OpenAiCodexModel, accessToken: string, - taskId?: string, + effectiveSessionId: string, ): ApiStream { // Per the implementation guide: route to Codex backend with Bearer token const url = `${CODEX_API_BASE_URL}/responses` @@ -504,16 +592,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Build headers with required Codex-specific fields const headers: Record = { + ...this.buildCodexHeaders(model, effectiveSessionId, accountId), "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, - originator: "zoo-code", - session_id: taskId || this.sessionId, - "User-Agent": `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - } - - // Add ChatGPT-Account-Id if available (required for organization subscriptions) - if (accountId) { - headers["ChatGPT-Account-Id"] = accountId } try { @@ -1117,6 +1198,27 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion return selected && selected !== "disable" && selected !== "none" ? (selected as any) : undefined } + private buildCodexHeaders( + model: OpenAiCodexModel, + effectiveSessionId: string, + accountId?: string | null, + ): Record { + return { + originator: "zoo-code", + session_id: effectiveSessionId, + "User-Agent": `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, + ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), + ...(model.id === LUNA_MODEL_ID + ? { + "session-id": effectiveSessionId, + "x-session-affinity": effectiveSessionId, + version: LUNA_CODEX_VERSION, + "x-openai-internal-codex-responses-lite": "true", + } + : {}), + } + } + override getModel() { const modelId = this.options.apiModelId @@ -1172,8 +1274,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } const reasoningEffort = this.getReasoningEffort(model) + const serviceTier = getOpenAiCodexServiceTier(this.options) - const requestBody: any = { + const baseRequestBody: any = { model: model.id, input: [ { @@ -1183,16 +1286,22 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion ], stream: false, store: false, + ...(serviceTier ? { [SERVICE_TIER_KEY]: serviceTier } : {}), ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), } if (reasoningEffort) { - requestBody.reasoning = { + baseRequestBody.reasoning = { effort: reasoningEffort, summary: "auto" as const, } } + const requestBody = + model.id === LUNA_MODEL_ID + ? this.buildLunaRequestBody(baseRequestBody, this.sessionId) + : baseRequestBody + const url = `${CODEX_API_BASE_URL}/responses` // Get ChatGPT account ID for organization subscriptions @@ -1200,16 +1309,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Build headers with required Codex-specific fields const headers: Record = { + ...this.buildCodexHeaders(model, this.sessionId, accountId), "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, - originator: "zoo-code", - session_id: this.sessionId, - "User-Agent": `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - } - - // Add ChatGPT-Account-Id if available - if (accountId) { - headers["ChatGPT-Account-Id"] = accountId } const response = await fetch(url, { 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/openai.ts b/src/api/providers/openai.ts index ca0305aab7..9545068794 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -323,7 +323,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return response.choices?.[0]?.message.content || "" } catch (error) { if (error instanceof Error) { - throw new Error(`${this.providerName} completion error: ${error.message}`) + const wrapped = new Error(`${this.providerName} completion error: ${error.message}`, { cause: error }) + const source = error as Error & { status?: number; errorDetails?: unknown; code?: unknown } + const target = wrapped as Error & { status?: number; errorDetails?: unknown; code?: unknown } + if (source.status !== undefined) target.status = source.status + if (source.errorDetails !== undefined) target.errorDetails = source.errorDetails + if (source.code !== undefined) target.code = source.code + throw wrapped } throw error 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__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index 15e531b0b7..a14fffe550 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -640,6 +640,57 @@ describe("reasoning.ts", () => { expect(result).toBeUndefined() }) + + it("should fall back to the model default when the persisted effort is outside the capability array", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { reasoningEffort: "medium" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toEqual({ reasoning_effort: "max" }) + }) + + it("should not fall back to the model default when reasoning was explicitly disabled", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { enableReasoningEffort: false, reasoningEffort: "disable" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toBeUndefined() + }) + + it("should not fall back to the model default when the persisted effort is an explicit none", () => { + const modelWithEffortArray: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "max", + } + + const options = { + ...baseOptions, + model: modelWithEffortArray, + settings: { reasoningEffort: "none" } as ProviderSettings, + reasoningEffort: undefined, + } + + expect(getOpenAiReasoning(options)).toBeUndefined() + }) }) describe("Gemini reasoning (effort models)", () => { 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/reasoning.ts b/src/api/transform/reasoning.ts index 37a19bec55..c51111125a 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -129,6 +129,25 @@ export const getOpenAiReasoning = ({ reasoningEffort, settings, }: GetModelReasoningOptions): OpenAiReasoningParams | undefined => { + // A persisted effort from another provider may fall outside this model's + // capability array; fall back to the model's default effort (mirrors the + // Gemini path) so the request still carries a valid value. + const capability = model.supportsReasoningEffort + if ( + settings?.enableReasoningEffort !== false && + Array.isArray(capability) && + model.reasoningEffort && + (capability as ReadonlyArray).includes(model.reasoningEffort) && + settings?.reasoningEffort && + settings.reasoningEffort !== "disable" && + settings.reasoningEffort !== "none" && + !(capability as ReadonlyArray).includes(settings.reasoningEffort) + ) { + return { + reasoning_effort: model.reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"], + } + } + if (!shouldUseReasoningEffort({ model, settings })) return undefined if (reasoningEffort === "disable" || !reasoningEffort) return undefined 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/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 1fcdac0ed7..df47e83c21 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -446,4 +446,22 @@ describe("getEnvironmentDetails", () => { expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 5) }) + + // Regression test for https://github.com/Zoo-Code-Org/Zoo-Code/issues/1024 + // When ripgrep cannot be found (e.g. @vscode/ripgrep >=1.18 platform layout on + // Windows), listFiles throws "Could not find ripgrep binary". getEnvironmentDetails + // must not propagate this — the task should proceed to the API call, not hang at 0%. + it("should degrade gracefully when listFiles rejects with an Error", async () => { + ;(listFiles as Mock).mockRejectedValue(new Error("Could not find ripgrep binary")) + + const result = await getEnvironmentDetails(mockCline as Task, true) + expect(result).toContain("File listing unavailable: Could not find ripgrep binary") + }) + + it("should degrade gracefully when listFiles rejects with a non-Error value", async () => { + ;(listFiles as Mock).mockRejectedValue("unexpected string rejection") + + const result = await getEnvironmentDetails(mockCline as Task, true) + expect(result).toContain("File listing unavailable: unexpected string rejection") + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 141ffdf3e2..0e7d18a57a 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -241,18 +241,22 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (maxFiles === 0) { details += "(Workspace files context disabled. Use list_files to explore if needed.)" } else { - const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) - const { showRooIgnoredFiles = false } = state ?? {} - - const result = formatResponse.formatFilesList( - cline.cwd, - files, - didHitLimit, - cline.rooIgnoreController, - showRooIgnoredFiles, - ) - - details += result + try { + const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + const { showRooIgnoredFiles = false } = state ?? {} + + const result = formatResponse.formatFilesList( + cline.cwd, + files, + didHitLimit, + cline.rooIgnoreController, + showRooIgnoredFiles, + ) + + details += result + } catch (error) { + details += `(File listing unavailable: ${error instanceof Error ? error.message : String(error)})` + } } } } diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index 0b443b201c..d6fd17ba2f 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap similarity index 99% rename from src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap rename to src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap index 0b443b201c..d6fd17ba2f 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index cb00166d35..2a1533bfef 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap index cb00166d35..2a1533bfef 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap index 89b1231ad9..f633cbfcf2 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap @@ -130,7 +130,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 89b1231ad9..f633cbfcf2 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -130,7 +130,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap index 89b1231ad9..f633cbfcf2 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap @@ -130,7 +130,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap index cb00166d35..2a1533bfef 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index 2d2f3701ca..5660cd4def 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -120,7 +120,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index cb00166d35..2a1533bfef 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -118,7 +118,7 @@ Mode-specific Instructions: **CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.** -Unless told otherwise, if you want to save a plan file, put it in the /plans directory +Unless told otherwise, if you want to save a plan file, put it in the ./plans directory (a directory named "plans" relative to the workspace root, not the absolute filesystem path /plans) Rules: # Rules from .clinerules-architect: diff --git a/src/core/prompts/__tests__/add-custom-instructions.spec.ts b/src/core/prompts/__tests__/add-custom-instructions.spec.ts index 00d32c2c29..5c9d7edf2c 100644 --- a/src/core/prompts/__tests__/add-custom-instructions.spec.ts +++ b/src/core/prompts/__tests__/add-custom-instructions.spec.ts @@ -236,7 +236,7 @@ describe("addCustomInstructions", () => { expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/ask-mode-prompt.snap") }) - it("should exclude MCP server creation info when disabled", async () => { + it("should generate the prompt when the MCP hub has no servers", async () => { const mockMcpHub = createMockMcpHub(false) const prompt = await SYSTEM_PROMPT( @@ -255,8 +255,7 @@ describe("addCustomInstructions", () => { undefined, // partialReadsEnabled ) - expect(prompt).not.toContain("Creating an MCP Server") - expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap") + expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/no-mcp-servers.snap") }) it("should prioritize mode-specific rules for code mode", async () => { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 219918b029..c6c3c6910f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -430,23 +430,27 @@ export class TaskHistoryStore { * Invalidate a single task's cache entry (re-read from disk on next access). */ async invalidate(taskId: string): Promise { - try { - const item = await this.readTaskFile(taskId) - if (item) { - this.cache.set(taskId, item) - } else { + return this.withLock(async () => { + try { + const item = await this.readTaskFile(taskId) + if (item) { + this.cache.set(taskId, item) + } else { + this.cache.delete(taskId) + } + } catch { this.cache.delete(taskId) } - } catch { - this.cache.delete(taskId) - } + }) } /** - * Clear all in-memory cache and reload from index. + * Clear all in-memory cache entries; a subsequent `reconcile()` repopulates them from task files. */ - invalidateAll(): void { - this.cache.clear() + async invalidateAll(): Promise { + return this.withLock(async () => { + this.cache.clear() + }) } // ────────────────────────────── Migration ────────────────────────────── diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 75e0a16253..3b7e9041a4 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -440,6 +440,107 @@ describe("TaskHistoryStore", () => { expect(store.get("gone-task")).toBeUndefined() }) + + it("waits for an in-flight write before refreshing the cache", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "invalidate-locked", tokensIn: 100 }) + await store.upsert(item) + + let signalWriteStarted!: () => void + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve + }) + let releaseWrite!: () => void + const writeCanFinish = new Promise((resolve) => { + releaseWrite = resolve + }) + let releaseStaleRead!: () => void + const staleReadCanFinish = new Promise((resolve) => { + releaseStaleRead = resolve + }) + let writeReleased = false + + const storeAny = store as any + const originalWriteTaskFile = storeAny.writeTaskFile.bind(store) + const originalReadTaskFile = storeAny.readTaskFile.bind(store) + vi.spyOn(storeAny, "writeTaskFile").mockImplementation(async (...args: unknown[]) => { + const next = args[0] as HistoryItem + if (next.id === item.id && next.tokensIn === 999) { + signalWriteStarted() + await writeCanFinish + } + return originalWriteTaskFile(...args) + }) + vi.spyOn(storeAny, "readTaskFile").mockImplementation(async (...args: unknown[]) => { + if (args[0] === item.id && !writeReleased) { + await staleReadCanFinish + return item + } + return originalReadTaskFile(...args) + }) + + const write = store.upsert({ ...item, tokensIn: 999 }) + await writeStarted + const invalidation = store.invalidate(item.id) + + writeReleased = true + releaseWrite() + await write + releaseStaleRead() + await invalidation + + expect(store.get(item.id)?.tokensIn).toBe(999) + }) + }) + + describe("invalidateAll()", () => { + it("waits for an in-flight write before clearing the cache", async () => { + const onWrite = vi.fn().mockResolvedValue(undefined) + store = new TaskHistoryStore(tmpDir, { onWrite }) + await store.initialize() + + const first = makeHistoryItem({ id: "invalidate-all-first", ts: 1000, tokensIn: 100 }) + const second = makeHistoryItem({ id: "invalidate-all-second", ts: 2000 }) + await store.upsert(first) + await store.upsert(second) + onWrite.mockClear() + + let signalWriteStarted!: () => void + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve + }) + let releaseWrite!: () => void + const writeCanFinish = new Promise((resolve) => { + releaseWrite = resolve + }) + + const storeAny = store as any + const originalWriteTaskFile = storeAny.writeTaskFile.bind(store) + vi.spyOn(storeAny, "writeTaskFile").mockImplementation(async (...args: unknown[]) => { + const item = args[0] as HistoryItem + if (item.id === first.id && item.tokensIn === 999) { + signalWriteStarted() + await writeCanFinish + } + return originalWriteTaskFile(...args) + }) + + const write = store.upsert({ ...first, tokensIn: 999 }) + await writeStarted + const invalidation = store.invalidateAll() + + releaseWrite() + await write + await invalidation + + expect(onWrite).toHaveBeenCalledTimes(1) + expect(onWrite.mock.calls[0][0].map((item: HistoryItem) => item.id).sort()).toEqual([ + "invalidate-all-first", + "invalidate-all-second", + ]) + expect(store.getAll()).toEqual([]) + }) }) describe("atomicUpdatePair()", () => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7bee3c5e14..8f69a3a0d4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -54,6 +54,7 @@ import { ConsecutiveMistakeError, MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" @@ -405,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 @@ -487,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 @@ -1371,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 } @@ -1396,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 @@ -1811,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)) } @@ -1875,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) @@ -1998,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" @@ -4220,7 +4258,7 @@ export class Task extends EventEmitter implements TaskLike { // but uses allowedFunctionNames to restrict which tools can be called. // Other providers (Anthropic, OpenAI, etc.) don't support this feature yet, // so they continue to receive only the filtered tools for the current mode. - const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === "gemini" + const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini { const provider = this.providerRef.deref() diff --git a/src/core/task/TaskRegistry.ts b/src/core/task/TaskRegistry.ts new file mode 100644 index 0000000000..728870683d --- /dev/null +++ b/src/core/task/TaskRegistry.ts @@ -0,0 +1,119 @@ +import { type Task } from "./Task" + +/** + * Expand-Contract adapter replacing `clineStack: Task[]` in ClineProvider. + * + * Phase A+B: adapter is live, all 23 call sites migrated, concurrent access + * methods available. Maintains a LIFO stack of task IDs alongside a Map for + * O(1) lookup. Invariant: tasks.size === stack.length at all times. + * + * Phase C (future): remove internal stack once all callers use map-based access. + */ +export class TaskRegistry { + private tasks = new Map() + private stack: string[] = [] + private _currentTaskId: string | undefined + + /** + * Add a task and focus it. If the task ID already exists, remove the + * existing entry first so this behaves as "move to top" instead of creating + * duplicate stack entries. + */ + push(task: Task): void { + if (this.tasks.has(task.taskId)) { + this.remove(task.taskId) + } + this.tasks.set(task.taskId, task) + this.stack.push(task.taskId) + this._currentTaskId = task.taskId + } + + pop(): Task | undefined { + const id = this.stack.pop() + if (id === undefined) return undefined + const task = this.tasks.get(id) + this.tasks.delete(id) + if (this._currentTaskId === id) { + this._currentTaskId = this.stack[this.stack.length - 1] + } + return task + } + + get current(): Task | undefined { + return this._currentTaskId !== undefined ? this.tasks.get(this._currentTaskId) : undefined + } + + /** + * Switch UI focus to a different task without mutating the stack order. + * Throws if the taskId is not in the registry. + */ + setCurrent(taskId: string): void { + if (!this.tasks.has(taskId)) { + throw new Error(`[TaskRegistry] setCurrent: unknown taskId ${taskId}`) + } + this._currentTaskId = taskId + } + + getById(id: string): Task | undefined { + return this.tasks.get(id) + } + + getAll(): Task[] { + return this.stack.map((id) => this.tasks.get(id)!) + } + + /** All tasks that are not aborted or abandoned. */ + getRunning(): Task[] { + return this.getAll().filter((t) => !t.abort && !t.abandoned) + } + + /** True only if the task is in the registry and is not aborted or abandoned. */ + hasRunning(taskId: string): boolean { + const t = this.tasks.get(taskId) + return t !== undefined && !t.abort && !t.abandoned + } + + /** Remove a task by ID regardless of its stack position. */ + remove(taskId: string): Task | undefined { + const task = this.tasks.get(taskId) + if (task === undefined) return undefined + this.tasks.delete(taskId) + const idx = this.stack.indexOf(taskId) + if (idx !== -1) this.stack.splice(idx, 1) + if (this._currentTaskId === taskId) { + this._currentTaskId = this.stack[this.stack.length - 1] + } + return task + } + + /** + * Replace a task in-place: same stack index, same current pointer. + * Used by rehydration so focus and stack order are both preserved. + * Throws if taskId is not in the registry. + */ + replace(taskId: string, replacement: Task): Task { + const idx = this.stack.indexOf(taskId) + if (idx === -1) { + throw new Error(`[TaskRegistry] replace: unknown taskId ${taskId}`) + } + if (replacement.taskId !== taskId && this.tasks.has(replacement.taskId)) { + throw new Error(`[TaskRegistry] replace: duplicate taskId ${replacement.taskId}`) + } + const old = this.tasks.get(taskId)! + this.tasks.delete(taskId) + this.tasks.set(replacement.taskId, replacement) + this.stack[idx] = replacement.taskId + if (this._currentTaskId === taskId) { + this._currentTaskId = replacement.taskId + } + return old + } + + get length(): number { + return this.stack.length + } + + get taskIds(): string[] { + return [...this.stack] + } +} 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 0c784821c1..d9f7240c5c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6,7 +6,13 @@ import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" -import type { GlobalState, ProviderSettings, ModelInfo } from "@roo-code/types" +import { + providerIdentifiers, + type GlobalState, + type ProviderSettings, + type ModelInfo, + type TaskLike, +} from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" @@ -17,6 +23,27 @@ import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import type { ApiMessage } from "../../task-persistence" + +type TaskTestAccess = { + getSystemPrompt: () => Promise + startTask: (task?: string, images?: string[]) => Promise + resumeTaskFromHistory: () => Promise + presentAssistantMessageSafe: () => void + updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise + saveClineMessages: () => Promise +} + +function getTaskTestAccess(task: Task): TaskTestAccess { + return task as unknown as TaskTestAccess +} + +function requireDefined(value: T | null | undefined): T { + if (value == null) { + throw new Error("Expected test value to be defined") + } + return value +} // Mock delay before any imports that might use it vi.mock("delay", () => ({ @@ -160,8 +187,22 @@ 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()) as any + const actual = await importOriginal() return { ...actual, summarizeConversation: vi.fn().mockResolvedValue({ @@ -274,11 +315,11 @@ describe("Cline", () => { mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), - ) as any + ) // Setup mock API configuration mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", // Add API key to mock config } @@ -400,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 () => { @@ -409,7 +486,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockStream = { async *[Symbol.asyncIterator]() { @@ -437,7 +514,7 @@ describe("Cline", () => { ts: Date.now(), extraProp: "should be removed", }, - ] as any + ] as Array const iterator = cline.attemptApiRequest(0) await iterator.next() @@ -480,7 +557,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(withImages as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(withImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withImages.api, "getModel").mockReturnValue({ id: "claude-3-sonnet", @@ -503,7 +580,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(withoutImages as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(withoutImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withoutImages.api, "getModel").mockReturnValue({ id: "gpt-3.5-turbo", @@ -537,8 +614,8 @@ describe("Cline", () => { const withImagesSpy = vi.spyOn(withImages.api, "createMessage").mockReturnValue(mockStream) const withoutImagesSpy = vi.spyOn(withoutImages.api, "createMessage").mockReturnValue(mockStream) - withImages.apiConversationHistory = conversationHistory as any - withoutImages.apiConversationHistory = conversationHistory as any + withImages.apiConversationHistory = conversationHistory as ApiMessage[] + withoutImages.apiConversationHistory = conversationHistory as ApiMessage[] const withImagesIterator = withImages.attemptApiRequest(0) await withImagesIterator.next() @@ -582,7 +659,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) @@ -676,7 +753,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: clock, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockDelay = vi.fn().mockResolvedValue(undefined) vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) @@ -750,7 +827,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) @@ -919,7 +996,7 @@ describe("Cline", () => { beforeEach(() => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", rateLimitSeconds: 5, } @@ -966,7 +1043,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1004,7 +1081,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") // Spy on child.say to verify the emitted message type const saySpy = vi.spyOn(child, "say") @@ -1059,7 +1136,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1099,7 +1176,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) @@ -1125,7 +1202,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1160,7 +1237,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child1 as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child1), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream) @@ -1185,7 +1262,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child2 as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child2), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream) @@ -1215,7 +1292,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1250,7 +1327,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) @@ -1273,7 +1350,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: clock, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1312,7 +1389,7 @@ describe("Cline", () => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -1366,7 +1443,7 @@ describe("Cline", () => { // Test with Anthropic provider const anthropicConfig = { ...mockApiConfig, - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiModelId: "gpt-4", } const anthropicTask = new Task({ @@ -1380,7 +1457,7 @@ describe("Cline", () => { // Test with OpenRouter provider and Claude model const openrouterClaudeConfig = { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-opus", } const openrouterClaudeTask = new Task({ @@ -1393,7 +1470,7 @@ describe("Cline", () => { // Test with OpenRouter provider and non-Claude model const openrouterGptConfig = { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } const openrouterGptTask = new Task({ @@ -1415,7 +1492,7 @@ describe("Cline", () => { for (const modelId of claudeModelFormats) { const config = { - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, openAiModelId: modelId, } const task = new Task({ @@ -1444,7 +1521,7 @@ describe("Cline", () => { // Test with no model ID const noModelConfig = { - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, } const noModelTask = new Task({ provider: mockProvider, @@ -1629,7 +1706,7 @@ describe("Cline", () => { }) // Cast to TaskLike to ensure interface compliance - const taskLike = task as any // TaskLike interface from types package + const taskLike: TaskLike = task // Verify abortTask method exists and is callable expect(typeof taskLike.abortTask).toBe("function") @@ -1784,11 +1861,9 @@ describe("Cline", () => { const cancelSpy = vi.spyOn(task, "cancelCurrentRequest") // Mock other dispose operations - vi.spyOn(task.messageQueueService, "removeListener").mockImplementation( - () => task.messageQueueService as any, - ) + vi.spyOn(task.messageQueueService, "removeListener").mockImplementation(() => task.messageQueueService) vi.spyOn(task.messageQueueService, "dispose").mockImplementation(() => {}) - vi.spyOn(task, "removeAllListeners").mockImplementation(() => task as any) + vi.spyOn(task, "removeAllListeners").mockImplementation(() => task) // Call dispose task.dispose() @@ -1806,7 +1881,7 @@ describe("Cline", () => { }) task.currentRequestAbortController = new AbortController() - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -1823,7 +1898,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -1842,7 +1917,7 @@ describe("Cline", () => { }) // Mock required methods for attemptApiRequest to work without hanging - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, @@ -1889,7 +1964,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -1902,6 +1977,55 @@ describe("Cline", () => { expect(metadata!.abortSignal).toBe(task.currentRequestAbortController!.signal) }) + it("configures tool restrictions for Gemini requests", async () => { + const apiConfiguration = { + ...mockApiConfig, + apiProvider: providerIdentifiers.gemini, + } as ProviderSettings + const task = new Task({ + provider: mockProvider, + apiConfiguration, + task: "test task", + startTask: false, + }) + + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: requireDefined(mockApiConfig.apiModelId), + info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo, + }) + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + }) + const mockStream = (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })() + const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest(0).next() + + const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) + const tools = requireDefined(metadata?.tools) + const allowedFunctionNames = requireDefined(metadata?.allowedFunctionNames) + const toolNames = tools.map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + + expect(tools.length).toBeGreaterThan(0) + expect(allowedFunctionNames.length).toBeGreaterThan(0) + expect(allowedFunctionNames.every((name) => toolNames.includes(name))).toBe(true) + }) + it("should invoke abort on currentRequestAbortController during first-chunk wait", async () => { const task = new Task({ provider: mockProvider, @@ -1930,7 +2054,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -1991,7 +2115,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const streamIterator = task.attemptApiRequest(0) await expect(streamIterator.next()).resolves.toMatchObject({ @@ -2014,7 +2138,7 @@ describe("Cline", () => { }) // Mock required methods for attemptApiRequest to work without hanging - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, @@ -2061,7 +2185,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -2082,7 +2206,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2126,7 +2250,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] expect(task.currentRequestAbortController).toBeUndefined() @@ -2147,7 +2271,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2191,7 +2315,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -2210,7 +2334,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2246,7 +2370,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] // First request const iterator1 = task.attemptApiRequest(0) @@ -2284,7 +2408,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, @@ -2325,7 +2449,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] let firstCall = true const retryStream = { @@ -2393,7 +2517,7 @@ describe("Cline", () => { }) // Manually trigger start - const startTaskSpy = vi.spyOn(task as any, "startTask").mockImplementation(async () => {}) + const startTaskSpy = vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => {}) task.start() expect(startTaskSpy).toHaveBeenCalledTimes(1) @@ -2406,7 +2530,9 @@ describe("Cline", () => { it("should not call startTask if already started via constructor", () => { // Create a task that starts immediately (startTask defaults to true) // but mock startTask to prevent actual execution - const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => {}) + const startTaskSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "startTask") + .mockImplementation(async () => {}) const task = new Task({ provider: mockProvider, @@ -2447,9 +2573,11 @@ describe("Cline", () => { it("logs (instead of crashing) when startTask rejects from the constructor", async () => { const boom = new Error("startTask boom") - const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => { - throw boom - }) + const startTaskSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "startTask") + .mockImplementation(async () => { + throw boom + }) new Task({ provider: mockProvider, @@ -2467,9 +2595,11 @@ describe("Cline", () => { it("logs (instead of crashing) when resumeTaskFromHistory rejects from the constructor", async () => { const boom = new Error("resume boom") - const resumeSpy = vi.spyOn(Task.prototype as any, "resumeTaskFromHistory").mockImplementation(async () => { - throw boom - }) + const resumeSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "resumeTaskFromHistory") + .mockImplementation(async () => { + throw boom + }) new Task({ provider: mockProvider, @@ -2526,7 +2656,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "startTask").mockImplementation(async () => { + vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => { throw boom }) @@ -2556,7 +2686,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() task.abort = true - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2579,7 +2709,7 @@ describe("Cline", () => { }) expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2610,7 +2740,7 @@ describe("Cline", () => { // Simulate the TOCTOU race: abort flips between throw and catch. task.abort = true - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2640,7 +2770,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2657,9 +2787,11 @@ describe("Cline", () => { // consumer-attached listener — that path must surface as a log, // not an unhandled rejection. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) const task = new Task({ provider: mockProvider, @@ -2689,10 +2821,12 @@ describe("Cline", () => { // Pins the symmetric .catch arm on the fire-and-forget // updateClineMessage call in ask() when finalizing a partial. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) - const saveSpy = vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(true) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, @@ -2728,9 +2862,11 @@ describe("Cline", () => { // in ask() when a new partial ask arrives while the previous partial // is still pending (AskIgnoredError path). const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) const task = new Task({ provider: mockProvider, @@ -2761,10 +2897,12 @@ describe("Cline", () => { // Pins the .catch arm on the fire-and-forget updateClineMessage call // in handleWebviewAskResponse when marking a tool ask as answered. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) - vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(undefined) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) + vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(false) const task = new Task({ provider: mockProvider, @@ -2827,7 +2965,12 @@ describe("Queued message processing after condense", () => { dispose: vi.fn(), } - const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any + const provider = new ClineProvider( + ctx, + output as unknown as vscode.OutputChannel, + "sidebar", + new ContextProxy(ctx), + ) provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) @@ -2836,10 +2979,10 @@ describe("Queued message processing after condense", () => { } const apiConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", - } as any + } it("processes queued message after condense completes", async () => { const provider = createProvider() @@ -2851,7 +2994,7 @@ describe("Queued message processing after condense", () => { }) // Make condense fast + deterministic - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("system") const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) // Queue a message during condensing @@ -2886,8 +3029,8 @@ describe("Queued message processing after condense", () => { startTask: false, }) - vi.spyOn(taskA as any, "getSystemPrompt").mockResolvedValue("system") - vi.spyOn(taskB as any, "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(taskA), "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(taskB), "getSystemPrompt").mockResolvedValue("system") const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined) const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined) @@ -2922,7 +3065,7 @@ describe("pushToolResultToUserContent", () => { beforeEach(() => { mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } @@ -2965,7 +3108,7 @@ describe("pushToolResultToUserContent", () => { mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), - ) as any + ) mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) diff --git a/src/core/task/__tests__/TaskRegistry.spec.ts b/src/core/task/__tests__/TaskRegistry.spec.ts new file mode 100644 index 0000000000..4493d83277 --- /dev/null +++ b/src/core/task/__tests__/TaskRegistry.spec.ts @@ -0,0 +1,301 @@ +import { TaskRegistry } from "../TaskRegistry" +import { type Task } from "../Task" + +function makeTask(taskId: string, opts: { abort?: boolean; abandoned?: boolean } = {}): Task { + return { taskId, abort: opts.abort ?? false, abandoned: opts.abandoned ?? false } as unknown as Task +} + +function assertInvariant(registry: TaskRegistry) { + const ids = registry.taskIds + expect(ids.length).toBe(registry.length) + for (const id of ids) { + expect(registry.getById(id)).toBeDefined() + } + if (registry.current !== undefined) { + expect(registry.getById(registry.current.taskId)).toBe(registry.current) + } +} + +describe("TaskRegistry", () => { + describe("push / pop", () => { + it("push adds to registry and makes task current", () => { + const r = new TaskRegistry() + const t = makeTask("a") + r.push(t) + expect(r.length).toBe(1) + expect(r.current).toBe(t) + expect(r.getById("a")).toBe(t) + assertInvariant(r) + }) + + it("push of second task makes it current", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + expect(r.current).toBe(t2) + expect(r.length).toBe(2) + assertInvariant(r) + }) + + it("push replaces duplicate task ids", () => { + const r = new TaskRegistry() + const first = makeTask("a") + const second = makeTask("a") + r.push(first) + r.push(second) + expect(r.taskIds).toEqual(["a"]) + expect(r.length).toBe(1) + expect(r.current).toBe(second) + expect(r.getById("a")).toBe(second) + assertInvariant(r) + }) + + it("pop removes top task and restores previous current", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + const popped = r.pop() + expect(popped).toBe(t2) + expect(r.length).toBe(1) + expect(r.current).toBe(t1) + expect(r.getById("b")).toBeUndefined() + assertInvariant(r) + }) + + it("pop on empty registry returns undefined", () => { + const r = new TaskRegistry() + expect(r.pop()).toBeUndefined() + expect(r.length).toBe(0) + expect(r.current).toBeUndefined() + }) + + it("pop of last task leaves current undefined", () => { + const r = new TaskRegistry() + r.push(makeTask("a")) + r.pop() + expect(r.current).toBeUndefined() + expect(r.length).toBe(0) + assertInvariant(r) + }) + + it("pop of non-current top does not change focus", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + const t3 = makeTask("c") + r.push(t1) + r.push(t2) + r.push(t3) + r.setCurrent("a") + const popped = r.pop() + expect(popped).toBe(t3) + expect(r.current).toBe(t1) + expect(r.taskIds).toEqual(["a", "b"]) + assertInvariant(r) + }) + }) + + describe("getById", () => { + it("returns correct task by id", () => { + const r = new TaskRegistry() + const t = makeTask("x") + r.push(t) + expect(r.getById("x")).toBe(t) + }) + + it("returns undefined for unknown id", () => { + const r = new TaskRegistry() + expect(r.getById("nope")).toBeUndefined() + }) + }) + + describe("remove", () => { + it("removes task by id regardless of position", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + const t3 = makeTask("c") + r.push(t1) + r.push(t2) + r.push(t3) + const removed = r.remove("b") + expect(removed).toBe(t2) + expect(r.length).toBe(2) + expect(r.getById("b")).toBeUndefined() + expect(r.taskIds).toEqual(["a", "c"]) + assertInvariant(r) + }) + + it("remove of current task updates current to new top", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + r.remove("b") + expect(r.current).toBe(t1) + assertInvariant(r) + }) + + it("returns undefined for unknown id", () => { + const r = new TaskRegistry() + expect(r.remove("nope")).toBeUndefined() + }) + }) + + describe("replace", () => { + it("preserves stack index when replacing the current task", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + const t3 = makeTask("c") + const t2v2 = makeTask("b-v2") + r.push(t1) + r.push(t2) + r.push(t3) + r.setCurrent("b") + r.replace("b", t2v2) + expect(r.taskIds).toEqual(["a", "b-v2", "c"]) + expect(r.current).toBe(t2v2) + expect(r.getById("b")).toBeUndefined() + expect(r.getById("b-v2")).toBe(t2v2) + assertInvariant(r) + }) + + it("preserves stack index when replacing a non-current task", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + const t1v2 = makeTask("a-v2") + r.push(t1) + r.push(t2) + r.replace("a", t1v2) + expect(r.taskIds).toEqual(["a-v2", "b"]) + expect(r.current).toBe(t2) + assertInvariant(r) + }) + + it("returns the old task", () => { + const r = new TaskRegistry() + const t = makeTask("a") + const t2 = makeTask("a-v2") + r.push(t) + expect(r.replace("a", t2)).toBe(t) + }) + + it("throws for unknown taskId", () => { + const r = new TaskRegistry() + expect(() => r.replace("nope", makeTask("x"))).toThrow(/unknown taskId/) + }) + + it("throws when replacement task id already exists elsewhere", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + expect(() => r.replace("a", makeTask("b"))).toThrow(/duplicate taskId/) + expect(r.taskIds).toEqual(["a", "b"]) + expect(r.getById("a")).toBe(t1) + expect(r.getById("b")).toBe(t2) + assertInvariant(r) + }) + }) + + describe("setCurrent", () => { + it("changes current without mutating stack order", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + r.setCurrent("a") + expect(r.current).toBe(t1) + expect(r.taskIds).toEqual(["a", "b"]) + assertInvariant(r) + }) + + it("throws for unknown taskId", () => { + const r = new TaskRegistry() + expect(() => r.setCurrent("nope")).toThrow(/unknown taskId/) + }) + }) + + describe("getAll", () => { + it("returns tasks in stack order", () => { + const r = new TaskRegistry() + const t1 = makeTask("a") + const t2 = makeTask("b") + r.push(t1) + r.push(t2) + expect(r.getAll()).toEqual([t1, t2]) + }) + }) + + describe("getRunning / hasRunning", () => { + it("getRunning excludes aborted tasks", () => { + const r = new TaskRegistry() + r.push(makeTask("a")) + r.push(makeTask("b", { abort: true })) + r.push(makeTask("c")) + const running = r.getRunning() + expect(running.map((t) => t.taskId)).toEqual(["a", "c"]) + }) + + it("getRunning excludes abandoned tasks", () => { + const r = new TaskRegistry() + r.push(makeTask("a")) + r.push(makeTask("b", { abandoned: true })) + const running = r.getRunning() + expect(running.map((t) => t.taskId)).toEqual(["a"]) + }) + + it("hasRunning returns true for a live task", () => { + const r = new TaskRegistry() + r.push(makeTask("a")) + expect(r.hasRunning("a")).toBe(true) + }) + + it("hasRunning returns false for an aborted task", () => { + const r = new TaskRegistry() + r.push(makeTask("a", { abort: true })) + expect(r.hasRunning("a")).toBe(false) + }) + + it("hasRunning returns false for an unknown task", () => { + const r = new TaskRegistry() + expect(r.hasRunning("nope")).toBe(false) + }) + }) + + describe("taskIds / length", () => { + it("taskIds is a snapshot (not a live reference)", () => { + const r = new TaskRegistry() + r.push(makeTask("a")) + const ids = r.taskIds + r.push(makeTask("b")) + expect(ids).toEqual(["a"]) + }) + }) + + describe("dual-write invariant", () => { + it("holds after a sequence of mixed operations", () => { + const r = new TaskRegistry() + const tasks = (["a", "b", "c", "d"] as const).map((id) => makeTask(id)) + for (const t of tasks) { + r.push(t) + assertInvariant(r) + } + r.remove("b") + assertInvariant(r) + r.pop() + assertInvariant(r) + r.setCurrent("a") + assertInvariant(r) + }) + }) +}) 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 47e31d7e2d..2ee92edebc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -50,8 +50,11 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + providerIdentifiers, } 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" @@ -142,7 +145,7 @@ function runDelegationTransition( locks.set(parentTaskId, tail) - tail.finally(() => { + void tail.finally(() => { if (locks.get(parentTaskId) === tail) { locks.delete(parentTaskId) } @@ -151,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 @@ -164,17 +173,10 @@ export class ClineProvider private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel - private clineStack: Task[] = [] + private taskRegistry = new TaskRegistry() + private taskScheduler = new TaskScheduler() private delegationTransitionLocks?: Map> private cancelledDelegationChildIds = new Set() - // Marks a child whose cancellation is currently in flight, from the moment cancelTask() - // is invoked until its "interrupted" status write lands (or the cancel path bails out). - // removeClineFromStack()'s delegation repair must not run against a stale "active" read - // while this is set — otherwise a concurrent navigation (e.g. showTaskWithId(parentTaskId) - // from the user clicking "back to parent" right after hitting Stop) can win the race - // against cancelTask()'s own runDelegationTransition call and repair the parent to - // "active" before "interrupted" is ever persisted, permanently severing the delegation link. - private cancellingDelegationChildIds = new Set() private codeIndexStatusSubscription?: vscode.Disposable private codeIndexManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class @@ -213,7 +215,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "jul-2026-v3.70.0-gpt5.6-grok4.5-kenari" // v3.70.0 GPT-5.6 family, Grok 4.5 support, Kenari provider + public readonly latestAnnouncementId = "jul-2026-v3.72.0-moonshot-kimi-models-workflows" // v3.72.0 Moonshot/Kimi providers, new models, subtask/indexing improvements public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -234,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) @@ -288,7 +290,22 @@ export class ClineProvider // Create named listener functions so we can remove them later. const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) - const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + const onTaskCompleted = async (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + // Explicitly transition the task to "completed" so that any prior terminal + // status (e.g. "interrupted" from a previous cancel) is correctly overwritten. + // saveClineMessages() omits the status field for top-level tasks, which causes + // the store's merge to preserve a stale "interrupted" status after completion. + // interrupted → completed is a valid VALID_TRANSITIONS path. + try { + const existing = this.taskHistoryStore.get(taskId) + if (existing && existing.status !== "completed") { + await this.updateTaskHistory({ ...existing, status: "completed" }) + } + } catch (err) { + this.log( + `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } const onTaskAborted = async () => { @@ -447,14 +464,14 @@ export class ClineProvider this.log("Cloud profile synchronization is disabled in compatibility mode") } - // Adds a new Task instance to clineStack, marking the start of a new task. + // Adds a new Task instance to the registry, marking the start of a new task. // The instance is pushed to the top of the stack (LIFO order). // When the task is completed, the top instance is removed, reactivating the // previous task. async addClineToStack(task: Task) { // Add this cline instance into the stack that represents the order of // all the called tasks. - this.clineStack.push(task) + this.taskRegistry.push(task) task.emit(RooCodeEventName.TaskFocused) // Perform special setup provider specific tasks. @@ -471,7 +488,7 @@ export class ClineProvider async performPreparationTasks(cline: Task) { // LMStudio: We need to force model loading in order to read its context // size; we do it now since we're starting a task with that model selected. - if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === "lmstudio") { + if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === providerIdentifiers.lmstudio) { try { if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) { await forceFullModelDetailsLoad( @@ -488,22 +505,18 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). - async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) { - const callerStack = new Error().stack - - if (this.clineStack.length === 0) { + async removeClineFromStack() { + if (this.taskRegistry.length === 0) { return } - // Pop the top Cline instance from the stack. - let task = this.clineStack.pop() - + // Remove the focused Cline instance from the stack. + let task = this.taskRegistry.current if (task) { - // Capture delegation metadata before abort/dispose, since abortTask(true) - // is async and the task reference is cleared afterwards. - const childTaskId = task.taskId - const parentTaskId = task.parentTaskId + task = this.taskRegistry.remove(task.taskId) + } + if (task) { task.emit(RooCodeEventName.TaskUnfocused) try { @@ -527,75 +540,103 @@ export class ClineProvider // Make sure no reference kept, once promises end it will be // garbage collected. task = undefined + } + } - // Delegation-aware parent metadata repair: - // If the popped task was a delegated child, repair the parent's metadata - // so it transitions from "delegated" back to "active" and becomes resumable - // from the task history list. - // Skip when called from delegateParentAndOpenChild() during nested delegation - // transitions (A→B→C), where the caller intentionally replaces the active - // child and will update the parent to point at the new child. - if (parentTaskId && childTaskId && !options?.skipDelegationRepair) { - try { - await this.runDelegationTransition(parentTaskId, async () => { - const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) - - if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === childTaskId) { - // If the child is "interrupted", cancelTask already persisted that - // status and intentionally left the parent delegated so the child - // can resume and report back. Do not auto-repair in that case. - if (this.taskHistoryStore.get(childTaskId)?.status === "interrupted") { - this.log( - `[ClineProvider#removeClineFromStack] Skipping parent repair: child ${childTaskId} is interrupted`, - ) - return - } + /** + * Evicts the current task from the stack and, if it was an active delegated child, + * marks it interrupted so the parent stays delegated (rather than silently losing the link). + * + * Use this in place of bare removeClineFromStack() at any call site that is not itself + * part of a delegation transition (i.e. everywhere except delegateParentAndOpenChild, + * createTask with a parentTask, and reopenParentFromDelegation). + */ + public async evictCurrentTask(): Promise { + const current = this.getCurrentTask() + const storedHistory = current ? this.taskHistoryStore.get(current.taskId) : undefined + await this.removeClineFromStack() + if (storedHistory?.status === "active" && storedHistory.parentTaskId) { + await this.markDelegatedChildInterrupted({ + childTaskId: storedHistory.id, + parentTaskId: storedHistory.parentTaskId, + }) + } + } - // A cancellation for this child may be in flight (cancelTask() has - // marked it synchronously but its "interrupted" write hasn't landed - // yet, since both paths serialize on the same per-parent transition - // lock and this call won the race). Repairing here would clear - // awaitingChildId based on a stale "active" read and permanently - // sever the delegation link. Defer to cancelTask()'s own write instead. - if (this.cancellingDelegationChildIds.has(childTaskId)) { - this.log( - `[ClineProvider#removeClineFromStack] Skipping parent repair: cancellation for child ${childTaskId} is in flight`, - ) - return - } + /** + * Marks a live delegated child as "interrupted" when it is evicted without completing + * (e.g. user hits + for a new task, or navigates away while the child is still active). + * + * This preserves the delegation link — the parent stays "delegated" with awaitingChildId + * intact — so the user can later resume or abandon the interrupted child. It is the live- + * eviction counterpart to cancelTask()'s interruption path and to reopenParentFromDelegation() + * (which handles normal child completion). + * + * Must be called AFTER removeClineFromStack() so the live Task's final saveClineMessages() + * does not reattach the child's parentTaskId/rootTaskId over the interrupted status. + */ + private async markDelegatedChildInterrupted({ + childTaskId, + parentTaskId, + }: { + childTaskId: string + parentTaskId: string + }): Promise { + // Fast path: already interrupted (cancelTask beat us to it), nothing to do. + if (this.taskHistoryStore.get(childTaskId)?.status === "interrupted") { + this.log(`[markDelegatedChildInterrupted] Child ${childTaskId} already interrupted — skipping`) + return + } - assertValidTransition(parentHistory.status, "active") - await this.updateTaskHistory({ - ...parentHistory, - status: "active", - awaitingChildId: undefined, - delegatedToId: undefined, - }) - const repairMsg = - `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed). ` + - `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}` - this.log(repairMsg) - console.warn(repairMsg) - } - }) - } catch (err) { - // Non-fatal: log but do not block the pop operation. + try { + await this.runDelegationTransition(parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { this.log( - `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ - err instanceof Error ? err.message : String(err) - }`, + `[markDelegatedChildInterrupted] Parent ${parentTaskId} no longer delegated to child ${childTaskId} — skipping`, ) + return } - } + + // Prefer the in-memory store entry: it is written by delegateParentAndOpenChild + // with the correct parentTaskId before the child saves its first message. + // getTaskWithId reads from disk and may return an incomplete record (missing + // parentTaskId) if the child was evicted before its first saveClineMessages(). + const childHistory = + this.taskHistoryStore.get(childTaskId) ?? (await this.getTaskWithId(childTaskId)).historyItem + + // Re-check inside the lock to close the TOCTOU window with cancelTask() or + // a concurrent completion. Only proceed when the child is still "active"; + // any other terminal status (interrupted, completed) must not be overwritten. + if (childHistory?.status !== "active") { + this.log( + `[markDelegatedChildInterrupted] Child ${childTaskId} is no longer active (status=${childHistory?.status}) — skipping`, + ) + return + } + + const interruptedChild = { ...childHistory, status: "interrupted" as const } + await this.updateTaskHistory(interruptedChild) + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: interruptedChild }) + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: parentHistory }) + this.log( + `[markDelegatedChildInterrupted] Marked child ${childTaskId} interrupted; parent ${parentTaskId} stays delegated`, + ) + }) + } catch (err) { + this.log( + `[markDelegatedChildInterrupted] Failed for child ${childTaskId}: ${err instanceof Error ? err.message : String(err)}`, + ) } } getTaskStackSize(): number { - return this.clineStack.length + return this.taskRegistry.length } public getCurrentTaskStack(): string[] { - return this.clineStack.map((cline) => cline.taskId) + return this.taskRegistry.taskIds } // Pending Edit Operations Management @@ -650,8 +691,17 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") - // Clear all tasks from the stack. - while (this.clineStack.length > 0) { + // 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. + if (this.taskRegistry.length > 0) { + await this.evictCurrentTask() + } + while (this.taskRegistry.length > 0) { await this.removeClineFromStack() } @@ -687,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() @@ -822,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, @@ -852,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) @@ -892,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() } @@ -903,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() } @@ -970,7 +1020,7 @@ export class ClineProvider // Using .find() would miss stale tokens in duplicate/renamed profiles since handleZooCodeCallback // uses .filter() and updates all of them — the early-return guard must match. const allProfiles = await this.providerSettingsManager.listConfig() - const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway") + const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooGatewayProfiles.length === 0) { this.log("[ensureZooGatewayProfileSeeded] No zoo-gateway profile found, creating one") @@ -1021,7 +1071,7 @@ export class ClineProvider const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id if (!isRehydratingCurrentTask) { - await this.removeClineFromStack() + await this.evictCurrentTask() } // If the history item has a saved mode, restore it and its associated API configuration. @@ -1144,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, @@ -1153,29 +1203,29 @@ export class ClineProvider if (isRehydratingCurrentTask) { // Replace the current task in-place to avoid UI flicker - const stackIndex = this.clineStack.length - 1 + const oldTask = this.taskRegistry.current - // Properly dispose of the old task to ensure garbage collection - const oldTask = this.clineStack[stackIndex] + if (oldTask) { + // Abort the old task to stop running processes and mark as abandoned + try { + await oldTask.abortTask(true) + } catch (e) { + this.log( + `[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`, + ) + } - // Abort the old task to stop running processes and mark as abandoned - try { - await oldTask.abortTask(true) - } catch (e) { - this.log( - `[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`, - ) - } + // Remove event listeners from the old task + const cleanupFunctions = this.taskEventListeners.get(oldTask) + if (cleanupFunctions) { + cleanupFunctions.forEach((cleanup) => cleanup()) + this.taskEventListeners.delete(oldTask) + } - // Remove event listeners from the old task - const cleanupFunctions = this.taskEventListeners.get(oldTask) - if (cleanupFunctions) { - cleanupFunctions.forEach((cleanup) => cleanup()) - this.taskEventListeners.delete(oldTask) + // Replace in-place: preserves stack index and current pointer + this.taskRegistry.replace(oldTask.taskId, task) } - // Replace the task in the stack - this.clineStack[stackIndex] = task task.emit(RooCodeEventName.TaskFocused) // Perform preparation tasks and set up event listeners @@ -1184,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 @@ -1341,7 +1399,7 @@ export class ClineProvider window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" - Roo Code + Zoo Code
@@ -1420,7 +1478,7 @@ export class ClineProvider window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" - Roo Code + Zoo Code @@ -1831,14 +1889,14 @@ export class ClineProvider // Check if Zoo Gateway is the currently active profile by apiProvider identity, // not by profile name (profile names are user-renameable). - const isZooGatewayActive = currentSettings.apiProvider === "zoo-gateway" + const isZooGatewayActive = currentSettings.apiProvider === providerIdentifiers.zooGateway // Always scan ALL profiles and update every zoo-gateway profile with the new token. // This ensures renamed profiles, duplicate profiles, and inactive profiles all stay // in sync. The model lookup in requestRouterModels uses .find() which returns the // first zoo-gateway profile it finds — if that profile has a stale token, requests fail. const allProfiles = await this.providerSettingsManager.listConfig() - const zooProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway") + const zooProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooProfiles.length === 0) { // No existing zoo-gateway profile — create the canonical default. @@ -1996,13 +2054,7 @@ export class ClineProvider /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { - let task: Task | undefined - for (let i = this.clineStack.length - 1; i >= 0; i--) { - if (this.clineStack[i].taskId === taskId) { - task = this.clineStack[i] - break - } - } + const task = this.taskRegistry.getById(taskId) if (!task) { throw new Error(`Task with id ${taskId} not found in stack`) } @@ -2107,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 }) } /** @@ -2123,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 }) } /** @@ -2140,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 }) } /** @@ -2160,7 +2212,7 @@ export class ClineProvider ]) // Send marketplace data separately - this.postMessageToWebview({ + await this.postMessageToWebview({ type: "marketplaceData", organizationMcps: marketplaceResult.organizationMcps || [], marketplaceItems: marketplaceResult.marketplaceItems || [], @@ -2171,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: [], @@ -2526,6 +2578,22 @@ export class ClineProvider return false } })(), + kimiCodeIsAuthenticated: await (async () => { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + return await kimiCodeOAuthManager.isAuthenticated() + } catch { + return false + } + })(), + kimiCodeOAuthState: await (async () => { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + return kimiCodeOAuthManager.getState() + } catch { + return undefined + } + })(), ...zooCodeState, platform: process.platform, arch: process.arch, @@ -2954,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, }) @@ -2966,7 +3034,7 @@ export class ClineProvider } // Send initial status for the current workspace - this.postMessageToWebview({ + void this.postMessageToWebview({ type: "indexingStatusUpdate", values: currentManager.getCurrentStatus(), }) @@ -2978,11 +3046,7 @@ export class ClineProvider */ public getCurrentTask(): Task | undefined { - if (this.clineStack.length === 0) { - return undefined - } - - return this.clineStack[this.clineStack.length - 1] + return this.taskRegistry.current } private logWebviewHiddenDiagnostics(): void { @@ -2994,7 +3058,7 @@ export class ClineProvider `[Zoo Code] Webview hidden during active task.\n` + ` taskId: ${task.taskId}\n` + ` messageCount: ${task.clineMessages.length}\n` + - ` stackDepth: ${this.clineStack.length}\n` + + ` stackDepth: ${this.taskRegistry.length}\n` + ` timestamp: ${new Date().toISOString()}\n` + `If the panel appears gray after this, share this log with support@zoocode.dev`, ) @@ -3107,13 +3171,11 @@ export class ClineProvider diffFuzzyThreshold, } = await this.getState() - // Single-open-task invariant: always enforce for user-initiated top-level tasks + // Single-open-task invariant: always enforce for user-initiated top-level tasks. if (!parentTask) { - try { - await this.removeClineFromStack() - } catch { + await this.evictCurrentTask().catch(() => { // Non-fatal - } + }) } if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { @@ -3129,12 +3191,12 @@ export class ClineProvider task: text, images, experiments, - rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, + rootTask: this.taskRegistry.getAll()[0], parentTask, - taskNumber: this.clineStack.length + 1, + taskNumber: this.taskRegistry.length + 1, onCreated: this.taskCreationCallback, initialTodos: options.initialTodos, - // Ensure this task is present in clineStack before startTask() emits + // Ensure this task is present in the registry before startTask() emits // its initial state update, so state.currentTaskId is available ASAP. startTask: false, diffFuzzyThreshold, @@ -3144,7 +3206,7 @@ export class ClineProvider await this.addClineToStack(task) if (options.startTask !== false) { - task.start() + scheduleTask(this.taskScheduler, task, "createTask") } this.log( @@ -3162,21 +3224,7 @@ export class ClineProvider } console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - - // Mark this child as "cancellation in flight" synchronously, before any await, so a - // concurrent removeClineFromStack() (e.g. from the user navigating back to the parent - // right after clicking Stop) cannot win the race against this function's own - // runDelegationTransition call below and repair the parent from a stale "active" read - // before "interrupted" is persisted (see cancellingDelegationChildIds doc comment). - if (task.parentTaskId) { - this.cancellingDelegationChildIds.add(task.taskId) - } - - try { - await this.cancelTaskInternal(task) - } finally { - this.cancellingDelegationChildIds.delete(task.taskId) - } + await this.cancelTaskInternal(task) } private async cancelTaskInternal(task: Task): Promise { @@ -3316,8 +3364,8 @@ export class ClineProvider // Clear the current task without treating it as a subtask. // This is used when the user cancels a task that is not a subtask. public async clearTask(): Promise { - if (this.clineStack.length > 0) { - const task = this.clineStack[this.clineStack.length - 1] + const task = this.taskRegistry.current + if (task) { console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`) await this.removeClineFromStack() } @@ -3532,7 +3580,7 @@ export class ClineProvider // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. try { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } catch (error) { this.log( `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ @@ -3578,13 +3626,44 @@ export class ClineProvider // single lock acquisition — no concurrent writer can slip between the read and // write, and the pure updater cannot re-enter the lock (no deadlock). // Broadcast and cache invalidation happen outside the lock after it releases. + // + // If the parent is already "delegated" to a previous interrupted child (the user + // navigated back to the parent and continued working), we implicitly sever the old + // link here (delegated → active → delegated) so no explicit Abandon step is needed. + // The old awaited child's status is re-read INSIDE the updater (which runs + // synchronously under the store lock) so a concurrent abandon or completion cannot + // slip between the status snapshot and the write. An active child must never be + // silently detached. try { await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { - assertValidTransition(historyItem.status, "delegated") - const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) + let base = historyItem + if (historyItem.status === "delegated") { + // Re-read the awaited child's current status under the store lock. + const awaitedChildStatus = historyItem.awaitingChildId + ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status + : undefined + // Only sever the stale link when the old child is confirmed interrupted. + // If it is still active, throw so the rollback path cleans up the new child + // rather than silently detaching a live task. + if (awaitedChildStatus !== "interrupted") { + throw new Error( + `[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${awaitedChildStatus}, not interrupted`, + ) + } + // Implicit sever of the stale interrupted-child link. + // The old child keeps its interrupted status; we just clear the parent's pointer. + base = { + ...historyItem, + status: "active" as const, + awaitingChildId: undefined, + delegatedToId: undefined, + } + } + assertValidTransition(base.status, "delegated") + const childIds = Array.from(new Set([...(base.childIds ?? []), child.taskId])) return { - ...historyItem, - status: "delegated", + ...base, + status: "delegated" as const, delegatedToId: child.taskId, awaitingChildId: child.taskId, childIds, @@ -3607,7 +3686,7 @@ export class ClineProvider // Only pop the stack if the child we just created is still on top. // A concurrent delegation could have pushed another child since we created ours. if (this.getCurrentTask()?.taskId === child.taskId) { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } } catch (cleanupError) { this.log( @@ -3639,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 { @@ -3818,7 +3897,7 @@ export class ClineProvider // overwrite a "completed" status set later. const current = this.getCurrentTask() if (current?.taskId === childTaskId) { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } // 3+5) Atomically mark child completed and parent active in one lock acquisition. @@ -3905,6 +3984,106 @@ export class ClineProvider }) } + /** + * Explicitly sever a delegated parent-child link, e.g. when the user gives up on + * an "interrupted" subtask instead of resuming it. Unlike removeClineFromStack()'s + * automatic repair, this is user-initiated and works even while the child is + * "interrupted" (which removeClineFromStack intentionally leaves alone so the child + * can still resume and report back). Only interrupted children can be abandoned — a + * still-running child must be cancelled first, so its link is never severed mid-stream. + * + * Parent transitions delegated → active (its normal "no longer awaiting a child" + * state). The child's own status is left untouched (interrupted stays interrupted; + * VALID_TRANSITIONS only allows interrupted → completed) — only its parent/root + * links are cleared so a later resume-and-complete cannot reattach it. + */ + public async abandonSubtask(childTaskId: string): Promise { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + const parentTaskId = childHistory.parentTaskId + + if (!parentTaskId) { + return false + } + + // Only an interrupted (cancelled, not running) child may be abandoned. A still-running + // child must be cancelled first — severing the link out from under a live stream would + // orphan it silently instead of giving the user the normal cancel/resume flow. + if (childHistory.status !== "interrupted") { + this.log( + `[abandonSubtask] Aborting: child ${childTaskId} is not interrupted (status=${childHistory.status})`, + ) + return false + } + + return this.runDelegationTransition(parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { + this.log( + `[abandonSubtask] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + + `(status=${parentHistory?.status}, awaitingChildId=${parentHistory?.awaitingChildId})`, + ) + return false + } + + // Re-check inside the lock: the child may have been resumed (and be streaming again, + // or have completed) between the check above and acquiring the delegation transition lock. + const freshChild = this.taskHistoryStore.get(childTaskId) + if (freshChild?.status !== "interrupted") { + this.log( + `[abandonSubtask] Aborting: child ${childTaskId} is no longer interrupted (status=${freshChild?.status})`, + ) + return false + } + + assertValidTransition(parentHistory.status, "active") + + // Close the live child instance (if it's still the open task — the common case, + // since an interrupted child is rehydrated onto the stack after cancelTask) BEFORE + // clearing its persisted links. Task#saveClineMessages() rebuilds parentTaskId/ + // rootTaskId from the live (readonly) Task fields on every save, so any save that + // happens after we clear the persisted links — including abortTask's own final + // save — would silently reattach the child to its old parent. + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + await this.taskHistoryStore.atomicUpdatePair( + childTaskId, + parentTaskId, + (child) => ({ ...child, parentTaskId: undefined, rootTaskId: undefined }), + (parent) => ({ + ...parent, + status: "active" as const, + awaitingChildId: undefined, + delegatedToId: undefined, + }), + ) + this.recentTasksCache = undefined + + // Guard against a stale in-flight resume/completion (e.g. a resume that was already + // in progress when abandon was clicked) reattaching the child after the link above + // was cleared. AttemptCompletionTool re-reads parent status from the persisted store, + // not the live task's readonly parentTaskId field, so this is the authoritative gate. + this.cancelledDelegationChildIds.add(childTaskId) + + if (this.isViewLaunched) { + const updatedChild = this.taskHistoryStore.get(childTaskId) + const updatedParent = this.taskHistoryStore.get(parentTaskId) + if (updatedChild) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedChild }) + } + if (updatedParent) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent }) + } + } + + this.log(`[abandonSubtask] Severed link between parent ${parentTaskId} and child ${childTaskId}`) + return true + }) + } + /** * Convert a file path to a webview-accessible URI * This method safely converts file paths to URIs that can be loaded in the webview diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 555253c345..3513bd3bd5 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -3,9 +3,28 @@ import * as vscode from "vscode" import { ClineProvider } from "../ClineProvider" import { Task } from "../../task/Task" +import { TaskRegistry } from "../../task/TaskRegistry" import { ContextProxy } from "../../config/ContextProxy" import type { ProviderSettings, HistoryItem } from "@roo-code/types" +type MockTask = Partial & + Pick & { + parentTaskId?: string + rootTask?: { taskId: string } + parentTask?: { taskId: string } + cancelCurrentRequest?: ReturnType + isStreaming?: boolean + didFinishAbortingStream?: boolean + isWaitingForFirstChunk?: boolean + } +type CreatedHistoryTask = Awaited> + +function seedRegistry(provider: ClineProvider, ...tasks: unknown[]) { + const registry = new TaskRegistry() + for (const t of tasks) registry.push(t as unknown as Task) + provider["taskRegistry"] = registry +} + // Mock dependencies vi.mock("vscode", () => { const mockDisposable = { dispose: vi.fn() } @@ -257,10 +276,10 @@ vi.mock("../../task-persistence", async (importOriginal) => { describe("ClineProvider flicker-free cancel", () => { let provider: ClineProvider - let mockContext: any - let mockOutputChannel: any - let mockTask1: any - let mockTask2: any + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockTask1: MockTask + let mockTask2: MockTask let consoleLogSpy: ReturnType let consoleErrorSpy: ReturnType @@ -301,13 +320,13 @@ describe("ClineProvider flicker-free cancel", () => { keys: vi.fn().mockReturnValue([]), }, extensionUri: { fsPath: "/test/extension" }, - } + } as unknown as vscode.ExtensionContext // Setup mock output channel mockOutputChannel = { appendLine: vi.fn(), dispose: vi.fn(), - } + } as unknown as vscode.OutputChannel // Setup mock context proxy const mockContextProxy = { @@ -320,7 +339,12 @@ describe("ClineProvider flicker-free cancel", () => { } // Create provider instance - provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy as any) + provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + mockContextProxy as unknown as ContextProxy, + ) // Mock provider methods provider.getState = vi.fn().mockResolvedValue({ @@ -330,8 +354,8 @@ describe("ClineProvider flicker-free cancel", () => { provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) - // Mock private method using any cast - ;(provider as any).updateGlobalState = vi.fn().mockResolvedValue(undefined) + // Mock private method used by the rehydration path. + provider["updateGlobalState"] = vi.fn().mockResolvedValue(undefined) provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) provider.performPreparationTasks = vi.fn().mockResolvedValue(undefined) provider.getTaskWithId = vi.fn().mockImplementation((id) => @@ -371,7 +395,7 @@ describe("ClineProvider flicker-free cancel", () => { // Mock Task constructor vi.mocked(Task).mockImplementation(function () { - return mockTask2 as any + return mockTask2 as unknown as Task }) }) @@ -380,13 +404,13 @@ describe("ClineProvider flicker-free cancel", () => { }) it("should not remove current task from stack when rehydrating same taskId", async () => { - // Setup: Add a task to the stack first - ;(provider as any).clineStack = [mockTask1] + // Setup: Add a task to the registry first + seedRegistry(provider, mockTask1) // Mock event listeners for cleanup - ;(provider as any).taskEventListeners = new WeakMap() + provider["taskEventListeners"] = new WeakMap() const mockCleanupFunctions = [vi.fn(), vi.fn()] - ;(provider as any).taskEventListeners.set(mockTask1, mockCleanupFunctions) + provider["taskEventListeners"].set(mockTask1 as unknown as Task, mockCleanupFunctions) // Spy on removeClineFromStack to verify it's NOT called const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack") @@ -410,8 +434,9 @@ describe("ClineProvider flicker-free cancel", () => { expect(removeClineFromStackSpy).not.toHaveBeenCalled() // Verify the task was replaced in-place - expect((provider as any).clineStack).toHaveLength(1) - expect((provider as any).clineStack[0]).toBe(mockTask2) + const registry = provider["taskRegistry"] + expect(registry.length).toBe(1) + expect(registry.current).toBe(mockTask2) // Verify old event listeners were cleaned up expect(mockCleanupFunctions[0]).toHaveBeenCalled() @@ -422,12 +447,12 @@ describe("ClineProvider flicker-free cancel", () => { }) it("should remove task from stack when creating different task", async () => { - // Setup: Add a task to the stack first - ;(provider as any).clineStack = [mockTask1] + // Setup: Add a task to the registry first + seedRegistry(provider, mockTask1) // Spy on removeClineFromStack to verify it IS called const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockImplementation(async () => { - ;(provider as any).clineStack.pop() + provider["taskRegistry"].pop() }) // Create history item with different taskId @@ -450,12 +475,12 @@ describe("ClineProvider flicker-free cancel", () => { }) it("should handle empty stack gracefully during rehydration attempt", async () => { - // Setup: Empty stack - ;(provider as any).clineStack = [] + // Setup: Empty registry (default) + seedRegistry(provider) // Spy on removeClineFromStack const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockImplementation(async () => { - ;(provider as any).clineStack.pop() + provider["taskRegistry"].pop() }) // Create history item @@ -478,16 +503,18 @@ describe("ClineProvider flicker-free cancel", () => { }) it("should maintain task stack integrity during flicker-free replacement", async () => { - // Setup: Stack with multiple tasks + // Setup: Registry with parent task then current task const mockParentTask = { taskId: "parent-task", instanceId: "parent-instance", + abort: false, + abandoned: false, emit: vi.fn(), } - ;(provider as any).clineStack = [mockParentTask, mockTask1] - ;(provider as any).taskEventListeners = new WeakMap() - ;(provider as any).taskEventListeners.set(mockTask1, [vi.fn()]) + seedRegistry(provider, mockParentTask, mockTask1) + provider["taskEventListeners"] = new WeakMap() + provider["taskEventListeners"].set(mockTask1 as unknown as Task, [vi.fn()]) // Act: Rehydrate the current (top) task const historyItem: HistoryItem = { @@ -503,10 +530,50 @@ describe("ClineProvider flicker-free cancel", () => { await provider.createTaskWithHistoryItem(historyItem) - // Assert: Stack should maintain parent task and replace current task - expect((provider as any).clineStack).toHaveLength(2) - expect((provider as any).clineStack[0]).toBe(mockParentTask) - expect((provider as any).clineStack[1]).toBe(mockTask2) + // Assert: Registry should maintain parent task and replace current task + const registry = provider["taskRegistry"] + expect(registry.length).toBe(2) + expect(registry.getAll()[0]).toBe(mockParentTask) + expect(registry.getAll()[1]).toBe(mockTask2) + }) + + it("should preserve stack order when rehydrating a focused non-top task", async () => { + // Regression test for issue #1: if setCurrent() has focused a non-top task, + // rehydrating it must not move it to the top of the stack. + const mockTopTask = { + taskId: "top-task", + instanceId: "top-instance", + abort: false, + abandoned: false, + emit: vi.fn(), + } + + // Seed: [mockTask1 (focused), mockTopTask (top-of-stack)] + seedRegistry(provider, mockTask1, mockTopTask) + provider["taskRegistry"].setCurrent("task-1") + provider["taskEventListeners"] = new WeakMap() + provider["taskEventListeners"].set(mockTask1 as unknown as Task, [vi.fn()]) + + const historyItem: HistoryItem = { + id: "task-1", + number: 1, + task: "test task", + ts: Date.now(), + tokensIn: 100, + tokensOut: 200, + totalCost: 0.001, + workspace: "/test/workspace", + } + + await provider.createTaskWithHistoryItem(historyItem) + + const registry = provider["taskRegistry"] + // Stack order must be unchanged: replacement stays at index 0, top-task stays at index 1 + expect(registry.length).toBe(2) + expect(registry.getAll()[0]).toBe(mockTask2) + expect(registry.getAll()[1]).toBe(mockTopTask) + // Focus follows the replacement + expect(registry.current).toBe(mockTask2) }) it("marks a cancelled delegated child as interrupted and keeps parent delegated (preserving resume path)", async () => { @@ -552,7 +619,7 @@ describe("ClineProvider flicker-free cancel", () => { didFinishAbortingStream: true, isWaitingForFirstChunk: false, }) - ;(provider as any).clineStack = [mockTask1] + seedRegistry(provider, mockTask1) provider.getTaskWithId = vi.fn().mockImplementation((id) => { if (id === "child-1") { return Promise.resolve({ historyItem: childHistory }) @@ -561,12 +628,12 @@ describe("ClineProvider flicker-free cancel", () => { return Promise.resolve({ historyItem: parentHistory }) } throw new Error(`unexpected task lookup: ${id}`) - }) as any + }) as unknown as ClineProvider["getTaskWithId"] const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) const createTaskWithHistoryItemSpy = vi .spyOn(provider, "createTaskWithHistoryItem") - .mockResolvedValue(undefined as any) + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) await provider.cancelTask() @@ -618,7 +685,7 @@ describe("ClineProvider flicker-free cancel", () => { didFinishAbortingStream: true, isWaitingForFirstChunk: false, }) - ;(provider as any).clineStack = [mockTask1] + seedRegistry(provider, mockTask1) provider.getTaskWithId = vi.fn().mockImplementation((id) => { if (id === "child-1") { return Promise.resolve({ historyItem: childHistory }) @@ -627,12 +694,12 @@ describe("ClineProvider flicker-free cancel", () => { return Promise.reject(new Error("parent lookup failed")) } throw new Error(`unexpected task lookup: ${id}`) - }) as any + }) as unknown as ClineProvider["getTaskWithId"] const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) const createTaskWithHistoryItemSpy = vi .spyOn(provider, "createTaskWithHistoryItem") - .mockResolvedValue(undefined as any) + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) await provider.cancelTask() @@ -655,7 +722,7 @@ describe("ClineProvider flicker-free cancel", () => { rootTask: undefined, }), ) - expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(true) }) it("does not rehydrate a cancelled child when standalone persistence also fails", async () => { @@ -683,7 +750,7 @@ describe("ClineProvider flicker-free cancel", () => { didFinishAbortingStream: true, isWaitingForFirstChunk: false, }) - ;(provider as any).clineStack = [mockTask1] + seedRegistry(provider, mockTask1) provider.getTaskWithId = vi.fn().mockImplementation((id) => { if (id === "child-1") { return Promise.resolve({ historyItem: childHistory }) @@ -692,16 +759,16 @@ describe("ClineProvider flicker-free cancel", () => { return Promise.reject(new Error("parent lookup failed")) } throw new Error(`unexpected task lookup: ${id}`) - }) as any + }) as unknown as ClineProvider["getTaskWithId"] vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("standalone persist failed")) const createTaskWithHistoryItemSpy = vi .spyOn(provider, "createTaskWithHistoryItem") - .mockResolvedValue(undefined as any) + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) await expect(provider.cancelTask()).rejects.toThrow("standalone persist failed") expect(createTaskWithHistoryItemSpy).not.toHaveBeenCalled() - expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(true) }) it("marks a cancelled delegated child as 'interrupted' and keeps parent delegated", async () => { @@ -745,17 +812,17 @@ describe("ClineProvider flicker-free cancel", () => { didFinishAbortingStream: true, isWaitingForFirstChunk: false, }) - ;(provider as any).clineStack = [mockTask1] + seedRegistry(provider, mockTask1) provider.getTaskWithId = vi.fn().mockImplementation((id) => { if (id === "child-1") return Promise.resolve({ historyItem: childHistory }) if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) throw new Error(`unexpected task lookup: ${id}`) - }) as any + }) as unknown as ClineProvider["getTaskWithId"] const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) const createTaskWithHistoryItemSpy = vi .spyOn(provider, "createTaskWithHistoryItem") - .mockResolvedValue(undefined as any) + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) await provider.cancelTask() @@ -780,75 +847,9 @@ describe("ClineProvider flicker-free cancel", () => { ) }) - it("removeClineFromStack does not repair parent when child is interrupted", async () => { - const parentHistory: HistoryItem = { - id: "parent-1", - number: 1, - task: "parent task", - ts: Date.now(), - tokensIn: 10, - tokensOut: 20, - totalCost: 0.001, - workspace: "/test/workspace", - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - } - - const childTask = { - taskId: "child-1", - instanceId: "inst-child", - parentTaskId: "parent-1", - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), - } - ;(provider as any).clineStack = [childTask] - ;(provider as any).taskEventListeners = new Map() - // Seed the in-memory store so taskHistoryStore.get("child-1") returns interrupted - vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) => - id === "child-1" ? { status: "interrupted" } : undefined, - ) - - provider.getTaskWithId = vi.fn().mockImplementation((id) => { - if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) - throw new Error(`unexpected task lookup: ${id}`) - }) as any - - const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) - - await (provider as any).removeClineFromStack() - - // Parent must NOT be transitioned to active — it stays delegated - expect(updateTaskHistorySpy).not.toHaveBeenCalledWith( - expect.objectContaining({ id: "parent-1", status: "active" }), - ) - }) - - // Regression test for the race where a user clicks Stop on a freshly-delegated - // child and immediately navigates back to the parent (showTaskWithId), before - // cancelTask()'s own persistence of childHistory.status = "interrupted" has - // landed. Both cancelTask() and removeClineFromStack() serialize their parent - // writes through runDelegationTransition(parentTaskId, ...), but removeClineFromStack - // only skips its repair when taskHistoryStore.get(childTaskId)?.status === "interrupted". - // If removeClineFromStack's transition wins the race and runs while the store still - // reports "active" (the write from cancelTask() hasn't landed yet), it incorrectly - // repairs the parent to "active" and clears awaitingChildId, permanently severing - // the delegation link before the child ever gets a chance to report back. - it("removeClineFromStack does not repair parent when a cancellation for the child is in flight", async () => { - const parentHistory: HistoryItem = { - id: "parent-1", - number: 1, - task: "parent task", - ts: Date.now(), - tokensIn: 10, - tokensOut: 20, - totalCost: 0.001, - workspace: "/test/workspace", - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - } - + it("removeClineFromStack never mutates delegation metadata (pure lifecycle after refactor)", async () => { + // After the refactor, removeClineFromStack() is pure lifecycle: pop, abort, clean up. + // Delegation state is owned by reopenParentFromDelegation() and markDelegatedChildInterrupted(). const childTask = { taskId: "child-1", instanceId: "inst-child", @@ -856,34 +857,19 @@ describe("ClineProvider flicker-free cancel", () => { emit: vi.fn(), abortTask: vi.fn().mockResolvedValue(undefined), } - ;(provider as any).clineStack = [childTask] - ;(provider as any).taskEventListeners = new Map() - - // The store still reports "active" — cancelTask()'s write to "interrupted" - // has not landed yet. This is the pre-write window of the race. - vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) => - id === "child-1" ? { status: "active" } : undefined, - ) - - provider.getTaskWithId = vi.fn().mockImplementation((id) => { - if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) - throw new Error(`unexpected task lookup: ${id}`) - }) as any + seedRegistry(provider, childTask) + provider["taskEventListeners"] = new Map() + provider.getTaskWithId = vi.fn() as unknown as ClineProvider["getTaskWithId"] const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) - // Simulate cancelTask() having already synchronously marked this child as - // "being cancelled" before its own await chain reaches the history write. - ;(provider as any).cancellingDelegationChildIds.add("child-1") + await provider["removeClineFromStack"]() - await (provider as any).removeClineFromStack() - - // Parent must NOT be transitioned to active while the child's cancellation - // is still in flight — repairing here would clear awaitingChildId and - // permanently sever the delegation link before "interrupted" is persisted. - expect(updateTaskHistorySpy).not.toHaveBeenCalledWith( - expect.objectContaining({ id: "parent-1", status: "active" }), - ) + expect(provider["taskRegistry"].length).toBe(0) + expect(childTask.abortTask).toHaveBeenCalledWith(true) + // No history writes — lifecycle only + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(provider.getTaskWithId).not.toHaveBeenCalled() }) afterAll(() => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b1e5df93f9..b99e502f61 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,6 +1,7 @@ // pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import * as path from "path" +import { TaskRegistry } from "../../task/TaskRegistry" import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" @@ -11,10 +12,12 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -26,8 +29,10 @@ 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" // Mock setup must come before imports. vi.mock("../../prompts/sections/custom-instructions") @@ -166,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() })), }, @@ -199,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", () => ({ @@ -216,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(), } }), @@ -257,6 +295,11 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModelsFromCache: vi.fn().mockReturnValue(undefined), })) +vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ + hasLoadedFullDetails: vi.fn().mockReturnValue(false), + forceFullModelDetailsLoad: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("../../../services/zoo-code-auth", () => ({ getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), getCachedZooCodeToken: vi.fn(), @@ -351,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(), } }, @@ -520,6 +565,32 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + test("loads full model details when preparing an LM Studio task", async () => { + await provider.performPreparationTasks({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "test-model", + }, + } as Task) + + expect(forceFullModelDetailsLoad).toHaveBeenCalledWith("http://localhost:1234", "test-model") + }) + + test("does not reload full model details when the LM Studio model is already loaded", async () => { + vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + + await provider.performPreparationTasks({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "test-model", + }, + } as Task) + + expect(forceFullModelDetailsLoad).not.toHaveBeenCalled() + }) + test("resolveWebviewView hydrates the saved terminalProfile into the process-wide Terminal state", async () => { const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {}) // Seed the persisted setting so the real getState() returns it during hydration. @@ -543,6 +614,7 @@ describe("ClineProvider", () => { }) expect(mockWebviewView.webview.html).toContain("") + expect(mockWebviewView.webview.html).toContain("Zoo Code") }) describe("logWebviewHiddenDiagnostics", () => { @@ -751,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" }) @@ -760,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 @@ -1149,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" }) @@ -1247,7 +1343,7 @@ describe("ClineProvider", () => { }, } - provider.setValue("customModePrompts", existingPrompts) + await provider.setValue("customModePrompts", existingPrompts) // Test updating a prompt await messageHandler({ @@ -1457,7 +1553,7 @@ describe("ClineProvider", () => { test("handles case when no current task exists", async () => { // Clear the cline stack - ;(provider as any).clineStack = [] + Object.assign(provider, { taskRegistry: new TaskRegistry() }) // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] @@ -2239,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 @@ -2703,8 +3190,10 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2755,8 +3244,10 @@ describe("ClineProvider - Router Models", () => { litellm: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2853,8 +3344,10 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 4a76ab4858..fe1eac8e20 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import type { HistoryItem, ExtensionMessage } from "@roo-code/types" +import { RooCodeEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { ContextProxy } from "../../config/ContextProxy" @@ -780,4 +781,70 @@ describe("ClineProvider Task History Synchronization", () => { expect(item!.tokensIn).toBe(222) }) }) + + describe("taskCreationCallback — onTaskCompleted listener", () => { + function makeFakeTask(taskId: string) { + const listeners: Record unknown)[]> = {} + return { + taskId, + on: (event: string, fn: (...args: unknown[]) => unknown) => { + listeners[event] = listeners[event] ?? [] + listeners[event].push(fn) + }, + // Returns a promise that resolves when all async listeners have settled. + emit: async (event: string, ...args: unknown[]) => { + await Promise.all((listeners[event] ?? []).map((fn) => Promise.resolve(fn(...args)))) + }, + } + } + + it("writes completed status when task is not already completed", async () => { + const existing = createHistoryItem({ id: "task-cb-1", task: "T" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + const fakeTask = makeFakeTask("task-cb-1") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-1", {}, {}) + + const stored = provider.taskHistoryStore.get("task-cb-1") + expect(stored?.status).toBe("completed") + }) + + it("skips the write when task is already completed", async () => { + const existing = createHistoryItem({ id: "task-cb-2", task: "T", status: "completed" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + const updateSpy = vi.spyOn(provider, "updateTaskHistory") + + const fakeTask = makeFakeTask("task-cb-2") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-2", {}, {}) + + // updateTaskHistory is called initially to store the item, but should NOT be + // called again by onTaskCompleted since it's already completed. + const onTaskCompletedCalls = updateSpy.mock.calls.filter((c) => { + const item = c[0] as HistoryItem + return item?.id === "task-cb-2" && item?.status === "completed" + }) + // It was written with completed status already; the callback must not re-write. + expect(onTaskCompletedCalls.length).toBe(0) + }) + + it("logs and does not throw when updateTaskHistory rejects", async () => { + const existing = createHistoryItem({ id: "task-cb-3", task: "T" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + vi.spyOn(provider, "updateTaskHistory").mockRejectedValueOnce(new Error("disk full")) + const logSpy = vi.spyOn(provider as any, "log") + + const fakeTask = makeFakeTask("task-cb-3") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-3", {}, {}) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts new file mode 100644 index 0000000000..c1b6cf4dcf --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts @@ -0,0 +1,53 @@ +// npx vitest run src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { showErrorMessage: vi.fn() }, + workspace: { workspaceFolders: undefined }, +})) + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler — abandonSubtaskWithId", () => { + let provider: { abandonSubtask: ReturnType; log: (msg: string) => void } + + beforeEach(() => { + vi.clearAllMocks() + provider = { + abandonSubtask: vi.fn().mockResolvedValue(true), + log: vi.fn(), + } + }) + + it("calls provider.abandonSubtask with the message text", async () => { + await webviewMessageHandler(provider as unknown as ClineProvider, { + type: "abandonSubtaskWithId", + text: "child-task-99", + }) + + expect(provider.abandonSubtask).toHaveBeenCalledWith("child-task-99") + }) + + it("catches and logs errors from provider.abandonSubtask", async () => { + const err = new Error("sever failed") + provider.abandonSubtask!.mockRejectedValue(err) + + // webviewMessageHandler calls .catch() on the promise — it should not throw + await webviewMessageHandler(provider as unknown as ClineProvider, { + type: "abandonSubtaskWithId", + text: "child-task-99", + }) + + // Give the microtask queue a tick so the .catch() fires + await new Promise((r) => setTimeout(r, 0)) + + expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("sever failed")) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 13af478c07..a50e73cb16 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -111,8 +111,11 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(routerModels).toHaveProperty("openrouter") expect(routerModels).toHaveProperty("requesty") expect(routerModels).toHaveProperty("deepseek") + expect(routerModels).toHaveProperty("moonshot") expect(routerModels.deepseek).toEqual({}) + expect(routerModels.moonshot).toEqual({}) expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "deepseek" })) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "moonshot" })) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -292,4 +295,183 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { baseUrl: "http://stored:4000", }) }) + + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + moonshotApiKey: "stored-moonshot-key", + moonshotBaseUrl: "https://api.moonshot.ai/v1", + }, + }) + + getModelsMock.mockImplementation(async (options: any) => { + if (options?.provider === "moonshot") { + return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } + } + + switch (options?.provider) { + case "openrouter": + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case "requesty": + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case "vercel-ai-gateway": + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case "litellm": + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler( + mockProvider as any, + { + type: "requestRouterModels", + } as any, + ) + + expect(getModelsMock).toHaveBeenCalledWith({ + provider: "moonshot", + apiKey: "stored-moonshot-key", + baseUrl: "https://api.moonshot.ai/v1", + }) + + const call = (mockProvider.postMessageToWebview as any).mock.calls.find( + (c: any[]) => c[0]?.type === "routerModels", + ) + expect(call).toBeTruthy() + expect(call[0].routerModels.moonshot).toEqual({ + "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true }, + }) + }) + + it("flushes Moonshot cache when explicit apiKey provided via message values", async () => { + getModelsMock.mockResolvedValue({ + "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true }, + }) + + await webviewMessageHandler( + mockProvider as any, + { + type: "requestRouterModels", + values: { + moonshotApiKey: "new-moonshot-key", + moonshotBaseUrl: "https://api.moonshot.cn/v1", + }, + } as any, + ) + + // flushModels should have been called for moonshot + const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + expect(moonshotFlushCalls.length).toBe(1) + expect(moonshotFlushCalls[0][0]).toEqual({ + provider: "moonshot", + apiKey: "new-moonshot-key", + baseUrl: "https://api.moonshot.cn/v1", + }) + + // getModels should use the provided credentials + const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + expect(moonshotCalls.length).toBe(1) + expect(moonshotCalls[0][0]).toEqual({ + provider: "moonshot", + apiKey: "new-moonshot-key", + baseUrl: "https://api.moonshot.cn/v1", + }) + }) + + it("does not flush Moonshot cache when using stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + moonshotApiKey: "stored-moonshot-key", + }, + }) + + getModelsMock.mockImplementation(async (options: any) => { + if (options?.provider === "moonshot") { + return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } + } + + switch (options?.provider) { + case "openrouter": + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case "requesty": + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case "vercel-ai-gateway": + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case "litellm": + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler( + mockProvider as any, + { + type: "requestRouterModels", + } as any, + ) + + // flushModels should NOT have been called for moonshot + const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + expect(moonshotFlushCalls.length).toBe(0) + + // getModels should still have been called with stored credentials + const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + expect(moonshotCalls.length).toBe(1) + expect(moonshotCalls[0][0]).toEqual({ + provider: "moonshot", + apiKey: "stored-moonshot-key", + baseUrl: undefined, + }) + }) + + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + moonshotApiKey: "stored-moonshot-key", + }, + }) + + getModelsMock.mockImplementation(async (options: any) => { + if (options?.provider === "moonshot") { + throw new Error("Moonshot API error") + } + + switch (options?.provider) { + case "openrouter": + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case "requesty": + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case "vercel-ai-gateway": + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case "litellm": + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler( + mockProvider as any, + { + type: "requestRouterModels", + } as any, + ) + + // Should have posted an error for moonshot + const errorCall = (mockProvider.postMessageToWebview as any).mock.calls.find( + (c: any[]) => c[0]?.type === "singleRouterModelFetchResponse" && c[0]?.values?.provider === "moonshot", + ) + expect(errorCall).toBeTruthy() + expect(errorCall[0].success).toBe(false) + + // Aggregate entry should still be empty + const call = (mockProvider.postMessageToWebview as any).mock.calls.find( + (c: any[]) => c[0]?.type === "routerModels", + ) + expect(call).toBeTruthy() + expect(call[0].routerModels.moonshot).toEqual({}) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 12832da249..64ad6804df 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -294,6 +294,9 @@ describe("webviewMessageHandler - image mentions", () => { describe("webviewMessageHandler - requestOllamaModels", () => { beforeEach(() => { vi.clearAllMocks() + mockFlushModels.mockReset() + mockFlushModels.mockResolvedValue(undefined) + mockGetModels.mockReset() mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: { ollamaModelId: "model-1", @@ -331,6 +334,97 @@ describe("webviewMessageHandler - requestOllamaModels", () => { ollamaModels: mockModels, }) }) + + it("posts empty models response when no models are found", async () => { + mockGetModels.mockResolvedValue({}) + + await webviewMessageHandler(mockClineProvider, { + type: "requestOllamaModels", + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "ollamaModels", + ollamaModels: {}, + }) + }) + + it("posts empty models response with error message and logs to output on fetch failure", async () => { + mockGetModels.mockRejectedValue(new Error("Connection refused")) + + await webviewMessageHandler(mockClineProvider, { + type: "requestOllamaModels", + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "ollamaModels", + ollamaModels: {}, + error: "Connection refused", + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + "[requestOllamaModels] Failed to read models for http://localhost:1234: Connection refused", + ) + }) + + it("distinguishes a model cache refresh failure from a model read failure", async () => { + mockFlushModels.mockRejectedValue(new Error("Cache write failed")) + + await webviewMessageHandler(mockClineProvider, { + type: "requestOllamaModels", + values: { baseUrl: "https://ollama.example.com" }, + }) + + expect(mockGetModels).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith( + "[requestOllamaModels] Failed to refresh model cache for https://ollama.example.com: Cache write failed", + ) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "ollamaModels", + ollamaModels: {}, + error: "Cache write failed", + }) + }) + + it("uses baseUrl from message values over saved state", async () => { + const mockModels: ModelRecord = { + "remote-model": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + description: "Remote model", + }, + } + + mockGetModels.mockResolvedValue(mockModels) + + await webviewMessageHandler(mockClineProvider, { + type: "requestOllamaModels", + values: { + baseUrl: "https://ollama.example.com", + apiKey: "secret-key", + }, + }) + + // Should use the URL from message values, not the saved state + expect(mockFlushModels).toHaveBeenCalledWith( + { + provider: "ollama", + baseUrl: "https://ollama.example.com", + apiKey: "secret-key", + }, + true, + ) + expect(mockGetModels).toHaveBeenCalledWith({ + provider: "ollama", + baseUrl: "https://ollama.example.com", + apiKey: "secret-key", + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "ollamaModels", + ollamaModels: mockModels, + }) + }) }) describe("webviewMessageHandler - requestRouterModels", () => { @@ -401,8 +495,10 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -587,8 +683,10 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -647,8 +745,10 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -1395,7 +1495,7 @@ describe("zooCodeSignOut", () => { vi.clearAllMocks() }) - it("disconnects Zoo Code and clears tokens from all zoo-gateway profiles", async () => { + it("disconnects Zoo Code and clears tokens from all Zoo Gateway profiles", async () => { const { disconnectZooCode } = await import("../../../services/zoo-code-auth") const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) const saveConfig = vi.fn().mockResolvedValue(undefined) @@ -1467,3 +1567,148 @@ describe("zooCodeSignOut", () => { ) }) }) + +describe("webviewMessageHandler - kimiCodeSignIn", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + it("starts OAuth authorization and opens browser", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockResolvedValue({ + type: "kimi-code", + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 3600000, + }) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + + expect(mockStartAuthorization).toHaveBeenCalled() + expect(mockOpenExternal).toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("shows success message after successful authorization", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockResolvedValue({ + type: "kimi-code", + accessToken: "token", + refreshToken: "refresh", + expiresAt: Date.now() + 3600000, + }) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Successfully signed in to Kimi Code") + }) + + it("handles authorization failure", async () => { + const mockStartAuthorization = vi.fn().mockResolvedValue({ + userCode: "TEST-CODE", + verificationUri: "https://auth.kimi.com/device", + expiresAt: Date.now() + 600000, + }) + const mockWaitForAuthorization = vi.fn().mockRejectedValue(new Error("Authorization cancelled")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + waitForAuthorization: mockWaitForAuthorization, + }, + })) + + const mockOpenExternal = vi.fn().mockResolvedValue(true) + ;(vscode as any).env = { openExternal: mockOpenExternal } + ;(vscode as any).Uri = { parse: vi.fn((url: string) => url) } + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("handles startAuthorization error", async () => { + const mockStartAuthorization = vi.fn().mockRejectedValue(new Error("Network error")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + startAuthorization: mockStartAuthorization, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignIn" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("Kimi Code sign in failed")) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) + +describe("webviewMessageHandler - kimiCodeSignOut", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + it("clears credentials and shows success message", async () => { + const mockClearCredentials = vi.fn().mockResolvedValue(undefined) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + clearCredentials: mockClearCredentials, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignOut" }) + + expect(mockClearCredentials).toHaveBeenCalled() + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Signed out from Kimi Code") + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("handles sign out error", async () => { + const mockClearCredentials = vi.fn().mockRejectedValue(new Error("Clear failed")) + + vi.doMock("../../../integrations/kimi-code/oauth", () => ({ + kimiCodeOAuthManager: { + clearCredentials: mockClearCredentials, + }, + })) + + await webviewMessageHandler(mockClineProvider, { type: "kimiCodeSignOut" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kimi Code sign out failed.") + }) +}) 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 01abc5db98..7009343573 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -22,6 +22,7 @@ import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, getCompletionCheckpoint, + providerIdentifiers, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -562,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 @@ -629,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) @@ -785,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": @@ -811,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": @@ -825,13 +830,22 @@ 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 + .abandonSubtask(message.text!) + .catch((error) => + provider.log( + `[abandonSubtaskWithId] Failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ) break case "deleteMultipleTasksWithIds": { const ids = message.ids @@ -878,7 +892,7 @@ export const webviewMessageHandler = async ( break } case "exportTaskWithId": - provider.exportTaskWithId(message.text!) + await provider.exportTaskWithId(message.text!) break case "getTaskWithAggregatedCosts": { try { @@ -963,7 +977,7 @@ export const webviewMessageHandler = async ( // Refresh history whenever Roo tasks were found — even if all already existed — // so a retry after a partial-copy failure still reconciles the store. - provider.taskHistoryStore.invalidateAll() + await provider.taskHistoryStore.invalidateAll() await provider.taskHistoryStore.reconcile() await provider.taskHistoryStore.flushIndex() await provider.postStateToWebview() @@ -1041,8 +1055,10 @@ export const webviewMessageHandler = async ( lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": {}, kenari: {}, + "kimi-code": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1136,6 +1152,21 @@ export const webviewMessageHandler = async ( }) } + // Moonshot is conditional on apiKey + const moonshotApiKey = message?.values?.moonshotApiKey ?? apiConfiguration.moonshotApiKey + const moonshotBaseUrl = message?.values?.moonshotBaseUrl ?? apiConfiguration.moonshotBaseUrl + + if (moonshotApiKey) { + if (message?.values?.moonshotApiKey || message?.values?.moonshotBaseUrl) { + await flushModels({ provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, true) + } + + candidates.push({ + key: "moonshot", + options: { provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + }) + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default @@ -1170,6 +1201,22 @@ export const webviewMessageHandler = async ( options: { provider: "kenari", apiKey: kenariApiKey }, }) + if (!providerFilter || providerFilter === "kimi-code") { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const kimiCodeAuthMethod = + message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const kimiCodeApiKey = + kimiCodeAuthMethod === "api-key" + ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) + : await kimiCodeOAuthManager.getAccessToken() + if (kimiCodeApiKey) { + candidates.push({ + key: "kimi-code", + options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, + }) + } + } + // Apply single provider filter if specified const modelFetchPromises = providerFilter ? candidates.filter(({ key }) => key === providerFilter) @@ -1202,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, @@ -1211,7 +1258,7 @@ export const webviewMessageHandler = async ( } }) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "routerModels", routerModels, values: providerFilter ? { provider: requestedProvider } : undefined, @@ -1221,23 +1268,48 @@ export const webviewMessageHandler = async ( case "requestOllamaModels": { // Specific handler for Ollama models only. const { apiConfiguration: ollamaApiConfig } = await provider.getState() + // Prefer the baseUrl/apiKey from the message values (which reflect + // the user's unsaved edits in the settings form) over the saved + // state, so the refresh uses the URL the user is actually looking + // at — not the stale one from before they started editing. + const baseUrl = message.values?.baseUrl ?? ollamaApiConfig.ollamaBaseUrl + const apiKey = message.values?.apiKey ?? ollamaApiConfig.ollamaApiKey + const logBaseUrl = baseUrl || "http://localhost:11434" + const ollamaOptions = { + provider: "ollama" as const, + baseUrl, + apiKey, + } try { - const ollamaOptions = { - provider: "ollama" as const, - baseUrl: ollamaApiConfig.ollamaBaseUrl, - apiKey: ollamaApiConfig.ollamaApiKey, - } - // Flush cache and refresh to ensure fresh models. + // Refresh the cache before reading the models. Keep this error + // separate from the read below so diagnostics identify which + // cache operation failed. await flushModels(ollamaOptions, true) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`) + await provider.postMessageToWebview({ + type: "ollamaModels", + ollamaModels: {}, + error: errorMsg, + }) + break + } + try { const ollamaModels = await getModels(ollamaOptions) - if (Object.keys(ollamaModels).length > 0) { - provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels }) - } + // Always post a response so the webview refresh status can + // transition out of "loading" — even when no models are found. + await provider.postMessageToWebview({ type: "ollamaModels", ollamaModels }) } catch (error) { - // Silently fail - user hasn't configured Ollama yet - console.debug("Ollama models fetch failed:", error) + const errorMsg = error instanceof Error ? error.message : String(error) + provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) + await provider.postMessageToWebview({ + type: "ollamaModels", + ollamaModels: {}, + error: errorMsg, + }) } break } @@ -1261,7 +1333,7 @@ export const webviewMessageHandler = async ( } if (Object.keys(lmStudioModels).length > 0) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "lmStudioModels", lmStudioModels: lmStudioModels, }) @@ -1273,7 +1345,7 @@ export const webviewMessageHandler = async ( break } case "requestRooModels": { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", success: false, error: getRouterRemovalMessage(), @@ -1289,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] @@ -1334,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" }, }) @@ -1348,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" }, }) @@ -1357,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 }, }) @@ -1375,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) { @@ -1505,7 +1577,7 @@ export const webviewMessageHandler = async ( const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() if (customModesFilePath) { - openFile(customModesFilePath) + await openFile(customModesFilePath) } break @@ -1532,7 +1604,7 @@ export const webviewMessageHandler = async ( const mcpSettingsFilePath = await provider.getMcpHub()?.getMcpSettingsFilePath() if (mcpSettingsFilePath) { - openFile(mcpSettingsFilePath) + await openFile(mcpSettingsFilePath) } break @@ -1669,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 }), }) @@ -1752,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 @@ -2157,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)}`, @@ -2333,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, @@ -2343,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", @@ -2352,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, @@ -2364,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, @@ -2423,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, @@ -2433,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, @@ -2444,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", @@ -2455,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, @@ -2469,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, @@ -2547,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.") @@ -2601,6 +2673,45 @@ export const webviewMessageHandler = async ( } break } + case "kimiCodeSignIn": { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const device = await kimiCodeOAuthManager.startAuthorization() + await provider.postStateToWebview() + await vscode.env.openExternal( + vscode.Uri.parse(device.verificationUriComplete ?? device.verificationUri), + ) + void kimiCodeOAuthManager + .waitForAuthorization() + .then(async () => { + vscode.window.showInformationMessage("Successfully signed in to Kimi Code") + await provider.postStateToWebview() + }) + .catch(async (error) => { + provider.log(`Kimi Code OAuth failed: ${error}`) + await provider.postStateToWebview() + }) + } catch (error) { + provider.log(`Kimi Code OAuth failed: ${error}`) + vscode.window.showErrorMessage( + `Kimi Code sign in failed: ${error instanceof Error ? error.message : error}`, + ) + await provider.postStateToWebview() + } + break + } + case "kimiCodeSignOut": { + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + await kimiCodeOAuthManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from Kimi Code") + await provider.postStateToWebview() + } catch (error) { + provider.log(`Kimi Code sign out failed: ${error}`) + vscode.window.showErrorMessage("Kimi Code sign out failed.") + } + break + } case "rooCloudManualUrl": { if (!isCloudServiceAvailable()) { provider.log("CloudService unavailable; ignoring rooCloudManualUrl") @@ -2667,11 +2778,11 @@ export const webviewMessageHandler = async ( const allProfiles = await provider.providerSettingsManager.listConfig() // Check if Zoo Gateway is the currently active profile by apiProvider identity const currentSettings = provider.contextProxy.getProviderSettings() - const isZooGatewayActive = currentSettings.apiProvider === "zoo-gateway" + const isZooGatewayActive = currentSettings.apiProvider === providerIdentifiers.zooGateway const currentApiConfigName = provider.contextProxy.getValues().currentApiConfigName for (const entry of allProfiles) { - if (entry.apiProvider !== "zoo-gateway") { + if (entry.apiProvider !== providerIdentifiers.zooGateway) { continue } @@ -2918,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", @@ -2943,7 +3054,7 @@ export const webviewMessageHandler = async ( workspacePath: undefined, } - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: status, }) @@ -2963,7 +3074,7 @@ export const webviewMessageHandler = async ( )) const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey")) - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "codeIndexSecretStatus", values: { hasOpenAiKey, @@ -2981,7 +3092,7 @@ export const webviewMessageHandler = async ( try { const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: { systemStatus: "Error", @@ -3003,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}`)) } } } @@ -3026,7 +3137,7 @@ export const webviewMessageHandler = async ( return } manager.stopIndexing() - provider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: manager.getCurrentStatus(), }) @@ -3046,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(), }) @@ -3080,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(), }) @@ -3099,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, @@ -3109,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, @@ -3161,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, @@ -3169,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), @@ -3187,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, @@ -3201,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), @@ -3218,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, @@ -3334,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 })) } @@ -3464,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") @@ -3588,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", }) @@ -3599,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, }) @@ -3732,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 new file mode 100644 index 0000000000..608e190d04 --- /dev/null +++ b/src/eslint-suppressions.json @@ -0,0 +1,1792 @@ +{ + "__mocks__/fs/promises.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/abandonSubtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/api-subtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/delegation-concurrent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/delegation-events.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/extension.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "__tests__/history-resume-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 72 + } + }, + "__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/nested-delegation-resume.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/new-task-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "__tests__/provider-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "activate/CodeActionProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/__tests__/CodeActionProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "activate/__tests__/handleUri.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "activate/__tests__/registerCommands.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCodeActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCommands.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/registerTerminalActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/anthropic-vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/__tests__/anthropic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/base-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/bedrock-custom-arn.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/bedrock-error-handling.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "api/providers/__tests__/bedrock-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "api/providers/__tests__/bedrock-reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 38 + } + }, + "api/providers/__tests__/deepseek.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/gemini-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 33 + } + }, + "api/providers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/lite-llm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 36 + } + }, + "api/providers/__tests__/lm-studio-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/lmstudio.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/mimo.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 29 + } + }, + "api/providers/__tests__/minimax.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/native-ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 30 + } + }, + "api/providers/__tests__/openai-codex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-usage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/openai-native.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 79 + } + }, + "api/providers/__tests__/openai-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/openai-usage-tracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "api/providers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/__tests__/poe.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/qwen-code-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/sambanova.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/unbound.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/xai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/zai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/anthropic-vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/anthropic.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/base-openai-compatible-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/base-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/deepseek.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/lmstudio.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/ollama.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/fetchers/litellm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/gemini.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/lite-llm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/lm-studio.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/mimo.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/moonshot.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/native-ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openai-codex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/openai-native.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/poe.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/qwen-code.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/requesty.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/unbound.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/utils/__tests__/error-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/utils/__tests__/image-generation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/providers/utils/__tests__/timeout-config.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/utils/error-handler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/xai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/transform/__tests__/ai-sdk.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/anthropic-filter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/bedrock-converse-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/gemini-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/mistral-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/model-params.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/openai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 49 + } + }, + "api/transform/__tests__/r1-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/__tests__/responses-api-input.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/responses-api-stream.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/transform/ai-sdk.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/bedrock-converse-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/transform/caching/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/gemini-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/openai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/r1-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/responses-api-input.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/responses-api-stream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/transform/zai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/assistant-message/NativeToolCallParser.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/assistant-message/presentAssistantMessage.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/checkpoints/__tests__/checkpoint.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/checkpoints/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/condense/__tests__/condense.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/condense/__tests__/foldedFileContext.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/condense/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/config/ContextProxy.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/CustomModesManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/ProviderSettingsManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/config/__tests__/ContextProxy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/__tests__/CustomModesSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/config/__tests__/ModeConfig.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/config/__tests__/ProviderSettingsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/context-management/__tests__/context-management.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context-tracking/__tests__/FileContextTracker.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/context/context-management/__tests__/context-error-handling.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context/context-management/context-error-handling.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/diff/stats.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/environment/__tests__/getEnvironmentDetails.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/ignore/__tests__/RooIgnoreController.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/__tests__/processUserContentMentions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/mentions/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/processUserContentMentions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/message-manager/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/message-manager/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/__tests__/add-custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/get-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/responses-rooignore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/prompts/__tests__/system-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/prompts/sections/__tests__/custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 55 + } + }, + "core/prompts/sections/__tests__/system-info.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/prompts/tools/filter-tools-for-mode.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/taskMessages.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task-persistence/apiMessages.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task/Task.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/Task.dispose.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.persistence.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/task/__tests__/Task.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "core/task/__tests__/Task.sticky-profile-race.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.throttle.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/task/__tests__/apiConversationHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/ask-queued-message-drain.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 32 + } + }, + "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/task/__tests__/grace-retry-errors.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/grounding-sources.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/native-tools-filtering.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/new-task-isolation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task/__tests__/reasoning-preservation.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/task/__tests__/task-tool-history.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/apiConversationHistory.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/BaseTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/CodebaseSearchTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/GenerateImageTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/NewTaskTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/ReadFileTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/tools/ToolRepetitionDetector.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UpdateTodoListTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UseMcpToolTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/ReadCommandOutputTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/tools/__tests__/attemptCompletionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "core/tools/__tests__/editFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/__tests__/editTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/__tests__/executeCommand.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/tools/__tests__/executeCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/__tests__/generateImageTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/tools/__tests__/listFilesTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/tools/__tests__/mcpServerRestriction.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/tools/__tests__/newTaskTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "core/tools/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 98 + } + }, + "core/tools/__tests__/runSlashCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/searchReplaceTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/skillTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/updateTodoListTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/useMcpToolTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/tools/__tests__/validateToolUse.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/writeToFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/helpers/toolResultFormatting.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/validateToolUse.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/webview/ClineProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "core/webview/__tests__/ClineProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 198 + } + }, + "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 40 + } + }, + "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/diagnosticsHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/messageEnhancer.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/webview/__tests__/skillsMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 13 + } + }, + "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 56 + } + }, + "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "core/webview/messageEnhancer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/webviewMessageHandler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "extension.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-delete-queued-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-send-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "extension/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "i18n/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "i18n/setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/DiffViewProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/__tests__/DiffViewProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 311 + } + }, + "integrations/editor/__tests__/EditorUtils.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/kimi-code/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/export-markdown.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/extract-text.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "integrations/misc/__tests__/line-counter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/misc/__tests__/open-file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "integrations/terminal/__tests__/OutputInterceptor.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "integrations/terminal/__tests__/TerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "integrations/terminal/__tests__/TerminalProcess.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/terminal/__tests__/TerminalProfile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "integrations/terminal/__tests__/setupTerminalTests.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/bashStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/theme/getTheme.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/__tests__/zoo-code-auth.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/config-manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 89 + } + }, + "services/code-index/__tests__/orchestrator.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/__tests__/service-factory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 43 + } + }, + "services/code-index/embedders/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/code-index/embedders/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/mistral.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 15 + } + }, + "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "services/code-index/embedders/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/code-index/embedders/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/embedders/ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/openai-compatible.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/interfaces/vector-store.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/orchestrator.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/__tests__/file-watcher.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/processors/__tests__/parser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/code-index/processors/__tests__/scanner.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "services/code-index/processors/file-watcher.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/scanner.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/__tests__/provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "services/code-index/semble/__tests__/semble-cli.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/__tests__/semble-downloader.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 61 + } + }, + "services/code-index/semble/provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/semble-cli.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/semble-downloader.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/shared/__tests__/validation-helpers.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/shared/validation-helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 87 + } + }, + "services/code-index/vector-store/qdrant-client.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/glob/__tests__/gitignore-integration.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/glob/__tests__/gitignore-test.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/glob/__tests__/list-files-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "services/glob/__tests__/list-files.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/marketplace/MarketplaceManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/marketplace/SimpleInstaller.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/marketplace/__tests__/MarketplaceManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/marketplace/__tests__/SimpleInstaller.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/McpHub.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/mcp/McpOAuthClientProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mcp/McpServerManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/__tests__/McpHub.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 150 + } + }, + "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/mcp/__tests__/SecretStorageService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/utils/__tests__/callbackServer.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/mcp/utils/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/mcp/utils/callbackServer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/utils/oauth.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mdm/__tests__/MdmService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/ripgrep/__tests__/diagnostic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/roo-config/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/roo-config/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/rules/__tests__/rules.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/search/__tests__/file-search.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/skills/__tests__/SkillsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/tree-sitter/__tests__/helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/tree-sitter/__tests__/markdownParser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/__tests__/api.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "shared/__tests__/embeddingModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/__tests__/modes-empty-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/checkExistApiConfig.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/cost.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/parse-command.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/support-prompt.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "shared/tools.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/utils/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/__tests__/autoImportSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "utils/__tests__/enhance-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "utils/__tests__/git.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 95 + } + }, + "utils/__tests__/json-schema.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "utils/__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "utils/__tests__/outputChannelLogger.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "utils/__tests__/safeWriteJson.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "utils/__tests__/shell.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 46 + } + }, + "utils/__tests__/storage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "utils/__tests__/tiktoken.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/config.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/export.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/safeWriteJson.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "utils/tts.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "vitest.setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 5c866ef941..65965eb8d5 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -13,7 +13,8 @@ export default [ "no-empty": "off", "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-explicit-any": "off", + // Enforced; existing violations are suppressed in eslint-suppressions.json and cleaned up incrementally. + "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/ban-ts-comment": "off", }, @@ -33,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/extension.ts b/src/extension.ts index f55aa61405..f006b4b52b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { Terminal } from "./integrations/terminal/Terminal" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" +import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" @@ -157,6 +158,8 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize OpenAI Codex OAuth manager for ChatGPT subscription-based access. openAiCodexOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) + // Kimi Code OAuth tokens live only in VS Code SecretStorage, outside provider profile JSON/cloud sync. + kimiCodeOAuthManager.initialize(context) // Initialize Zoo Code auth service for extension session token management. await initZooCodeAuth(context) diff --git a/src/extension/api.ts b/src/extension/api.ts index c140ec8ec0..2d0d5a6975 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -192,7 +192,7 @@ export class API extends EventEmitter implements RooCodeAPI { provider = this.sidebarProvider } - await provider.removeClineFromStack() + await provider.evictCurrentTask() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) @@ -255,7 +255,7 @@ export class API extends EventEmitter implements RooCodeAPI { public async clearCurrentTask(_lastMessage?: string) { // Legacy finishSubTask removed; clear current by closing active task instance. - await this.sidebarProvider.removeClineFromStack() + await this.sidebarProvider.evictCurrentTask() await this.sidebarProvider.postStateToWebview() } @@ -263,6 +263,10 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.cancelTask() } + public async abandonSubtask(childTaskId: string): Promise { + return this.sidebarProvider.abandonSubtask(childTaskId) + } + public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() 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/integrations/kimi-code/__tests__/oauth.spec.ts b/src/integrations/kimi-code/__tests__/oauth.spec.ts new file mode 100644 index 0000000000..960777e69c --- /dev/null +++ b/src/integrations/kimi-code/__tests__/oauth.spec.ts @@ -0,0 +1,465 @@ +import { KIMI_CODE_OAUTH_CONFIG, KimiCodeOAuthManager } from "../oauth" + +const createContext = () => { + const values = new Map() + return { + values, + context: { + secrets: { + get: vi.fn(async (key: string) => values.get(key)), + store: vi.fn(async (key: string, value: string) => void values.set(key, value)), + delete: vi.fn(async (key: string) => void values.delete(key)), + }, + } as any, + } +} + +describe("KimiCodeOAuthManager", () => { + beforeEach(() => vi.restoreAllMocks()) + afterEach(() => vi.useRealTimers()) + + it("uses the official public client ID and form-encoded device request", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "ABCD-EFGH", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + const authorization = await manager.startAuthorization() + expect(authorization.userCode).toBe("ABCD-EFGH") + expect(fetch).toHaveBeenCalledWith( + KIMI_CODE_OAUTH_CONFIG.deviceAuthorizationEndpoint, + expect.objectContaining({ body: `client_id=${KIMI_CODE_OAUTH_CONFIG.clientId}` }), + ) + const cancelledPolling = manager.waitForAuthorization().catch((error) => error) + manager.cancelAuthorization() + await expect(cancelledPolling).resolves.toMatchObject({ message: "Kimi Code authorization was cancelled" }) + }) + + it("deduplicates concurrent access-token refreshes and stores refreshed credentials", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ access_token: "new", refresh_token: "rotated", expires_in: 3600 }), { + status: 200, + }), + ) + + const [first, second] = await Promise.all([manager.getAccessToken(), manager.getAccessToken()]) + expect(first).toBe("new") + expect(second).toBe("new") + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(context.secrets.store).toHaveBeenCalledTimes(1) + }) + + it("returns null when not authenticated", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(await manager.isAuthenticated()).toBe(false) + expect(await manager.getAccessToken()).toBeNull() + }) + + it("returns cached access token when not expired", async () => { + const { context, values } = createContext() + const futureExpiry = Date.now() + 3600_000 + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ + type: "kimi-code", + accessToken: "valid", + refreshToken: "refresh", + expiresAt: futureExpiry, + }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + expect(await manager.getAccessToken()).toBe("valid") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("clears credentials and cancels authorization", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "token", refreshToken: "refresh", expiresAt: 99999 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + await manager.clearCredentials() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + expect(await manager.isAuthenticated()).toBe(false) + }) + + it("does not restore credentials when sign-out races an in-flight refresh", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + let resolveRefresh!: (response: Response) => void + vi.spyOn(globalThis, "fetch").mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + const tokenPromise = manager.getAccessToken() + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + const clearPromise = manager.clearCredentials() + resolveRefresh( + new Response(JSON.stringify({ access_token: "new", refresh_token: "rotated", expires_in: 3600 }), { + status: 200, + }), + ) + + await expect(tokenPromise).resolves.toBeNull() + await clearPromise + expect(context.secrets.store).not.toHaveBeenCalled() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("bounds token refresh requests with a deadline", async () => { + vi.useFakeTimers() + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + }) + const tokenPromise = manager.getAccessToken() + + await vi.advanceTimersByTimeAsync(30_000) + await expect(tokenPromise).resolves.toBeNull() + expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(values.has("kimi-code-oauth-credentials")).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it("keeps the newer polling lifecycle when a prior poll finishes cancellation", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "first", + user_code: "FIRST", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "second", + user_code: "SECOND", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + await manager.startAuthorization() + const firstPolling = manager.waitForAuthorization().catch((error) => error) + await manager.startAuthorization() + const secondPolling = manager.waitForAuthorization().catch((error) => error) + await expect(firstPolling).resolves.toMatchObject({ message: "Kimi Code authorization was cancelled" }) + + expect(manager.getState()).toMatchObject({ status: "polling", userCode: "SECOND" }) + expect(() => manager.waitForAuthorization()).not.toThrow() + manager.cancelAuthorization() + await secondPolling + }) + + it("ignores a superseded device request failure", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + let rejectFirst!: (error: Error) => void + vi.spyOn(globalThis, "fetch") + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFirst = reject + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "second", + user_code: "SECOND", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 60, + }), + { status: 200 }, + ), + ) + + const first = manager.startAuthorization() + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + await manager.startAuthorization() + rejectFirst(new Error("stale request failed")) + await expect(first).rejects.toThrow("stale request failed") + + expect(manager.getState()).toMatchObject({ status: "polling", userCode: "SECOND" }) + expect(() => manager.waitForAuthorization()).not.toThrow() + const secondPolling = manager.waitForAuthorization().catch((error) => error) + manager.cancelAuthorization() + await secondPolling + }) + + it("handles polling completion successfully", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", refresh_token: "refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + await manager.startAuthorization() + const credentials = await manager.waitForAuthorization() + expect(credentials.accessToken).toBe("new") + expect(await manager.isAuthenticated()).toBe(true) + }) + + it("handles slow_down error during polling", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ error: "slow_down" }), { status: 400 })) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", refresh_token: "refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + await manager.startAuthorization() + const credentials = await manager.waitForAuthorization() + expect(credentials.accessToken).toBe("new") + }) + + it("handles authorization expiration", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 1, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 })) + + vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(2000) + + await manager.startAuthorization() + await expect(manager.waitForAuthorization()).rejects.toThrow("Kimi Code authorization expired") + }) + + it("handles device authorization errors", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_request", error_description: "Bad request" }), { + status: 400, + }), + ) + + await expect(manager.startAuthorization()).rejects.toThrow("Bad request") + }) + + it("handles token refresh failure with invalid_grant", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "invalid", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }), + ) + + const token = await manager.getAccessToken() + expect(token).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("handles refresh errors that preserve state", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ error: "server_error" }), { status: 500 }), + ) + + const token = await manager.getAccessToken() + expect(token).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(true) + }) + + it("forces refresh even when token is not expired", async () => { + const { context, values } = createContext() + const futureExpiry = Date.now() + 3600_000 + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "refresh", expiresAt: futureExpiry }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "forced", refresh_token: "new_refresh", expires_in: 3600 }), { + status: 200, + }), + ) + + const token = await manager.forceRefreshAccessToken() + expect(token).toBe("forced") + }) + + it("handles malformed OAuth error response", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("not json", { status: 400 })) + + await expect(manager.startAuthorization()).rejects.toThrow("OAuth request failed") + }) + + it("throws error when no refresh token in device token response", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + const fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device", + user_code: "CODE", + verification_uri: "https://auth.kimi.com/device", + expires_in: 600, + interval: 1, + }), + { status: 200 }, + ), + ) + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "token", expires_in: 3600 }), { status: 200 }), + ) + + await manager.startAuthorization() + await expect(manager.waitForAuthorization()).rejects.toThrow("did not return a refresh token") + }) + + it("returns state correctly", async () => { + const { context } = createContext() + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(manager.getState().status).toBe("idle") + }) + + it("throws when waitForAuthorization called without active authorization", () => { + const manager = new KimiCodeOAuthManager() + expect(() => manager.waitForAuthorization()).toThrow("No Kimi Code authorization is in progress") + }) + + it("handles invalid stored credentials", async () => { + const { context, values } = createContext() + values.set("kimi-code-oauth-credentials", "invalid json") + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + expect(await manager.getAccessToken()).toBeNull() + expect(values.has("kimi-code-oauth-credentials")).toBe(false) + }) + + it("preserves refresh token when not rotated", async () => { + const { context, values } = createContext() + values.set( + "kimi-code-oauth-credentials", + JSON.stringify({ type: "kimi-code", accessToken: "old", refreshToken: "keep", expiresAt: 0 }), + ) + const manager = new KimiCodeOAuthManager() + manager.initialize(context) + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 }), + ) + + await manager.getAccessToken() + const stored = JSON.parse(values.get("kimi-code-oauth-credentials")!) + expect(stored.refreshToken).toBe("keep") + }) +}) diff --git a/src/integrations/kimi-code/oauth.ts b/src/integrations/kimi-code/oauth.ts new file mode 100644 index 0000000000..cd28df8b04 --- /dev/null +++ b/src/integrations/kimi-code/oauth.ts @@ -0,0 +1,379 @@ +import type { ExtensionContext } from "vscode" +import { z } from "zod" + +export const KIMI_CODE_OAUTH_CONFIG = { + authHost: "https://auth.kimi.com", + deviceAuthorizationEndpoint: "https://auth.kimi.com/api/oauth/device_authorization", + tokenEndpoint: "https://auth.kimi.com/api/oauth/token", + deviceGrantType: "urn:ietf:params:oauth:grant-type:device_code", + // Kimi Code's official public client ID for the OAuth device flow. + // Source: Kimi Code CLI's published OAuth integration. + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", +} as const + +const KIMI_CODE_CREDENTIALS_KEY = "kimi-code-oauth-credentials" +const TOKEN_EXPIRY_BUFFER_MS = 60_000 +const OAUTH_REQUEST_TIMEOUT_MS = 30_000 + +const credentialsSchema = z.object({ + type: z.literal("kimi-code"), + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number(), + tokenType: z.string().optional(), +}) + +const deviceAuthorizationSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url().optional(), + expires_in: z.number().positive(), + interval: z.number().positive().optional(), +}) + +const tokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().positive(), + token_type: z.string().optional(), +}) + +const oauthErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}) + +export type KimiCodeCredentials = z.infer + +export type KimiCodeOAuthState = { + status: "idle" | "authorizing" | "polling" | "authenticated" | "error" + userCode?: string + verificationUri?: string + expiresAt?: number + error?: string +} + +export type KimiCodeDeviceAuthorization = { + userCode: string + verificationUri: string + verificationUriComplete?: string + expiresAt: number +} + +class KimiCodeOAuthError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message) + this.name = "KimiCodeOAuthError" + } +} + +async function postForm(endpoint: string, values: Record, signal?: AbortSignal): Promise { + const controller = new AbortController() + const forwardAbort = () => controller.abort(signal?.reason) + if (signal?.aborted) forwardAbort() + else signal?.addEventListener("abort", forwardAbort, { once: true }) + const timeout = setTimeout( + () => controller.abort(new Error("Kimi Code OAuth request timed out")), + OAUTH_REQUEST_TIMEOUT_MS, + ) + try { + return await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(values).toString(), + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + signal?.removeEventListener("abort", forwardAbort) + } +} + +async function readOAuthError(response: Response): Promise { + const text = await response.text() + try { + const parsed = oauthErrorSchema.parse(JSON.parse(text)) + return new KimiCodeOAuthError(parsed.error_description ?? parsed.error, parsed.error) + } catch { + return new KimiCodeOAuthError(`OAuth request failed: ${response.status} ${response.statusText} - ${text}`) + } +} + +export async function requestDeviceAuthorization(signal?: AbortSignal) { + const response = await postForm( + KIMI_CODE_OAUTH_CONFIG.deviceAuthorizationEndpoint, + { client_id: KIMI_CODE_OAUTH_CONFIG.clientId }, + signal, + ) + if (!response.ok) throw await readOAuthError(response) + return deviceAuthorizationSchema.parse(await response.json()) +} + +async function requestDeviceToken(deviceCode: string, signal?: AbortSignal): Promise { + const response = await postForm( + KIMI_CODE_OAUTH_CONFIG.tokenEndpoint, + { + grant_type: KIMI_CODE_OAUTH_CONFIG.deviceGrantType, + device_code: deviceCode, + client_id: KIMI_CODE_OAUTH_CONFIG.clientId, + }, + signal, + ) + if (!response.ok) throw await readOAuthError(response) + const tokens = tokenResponseSchema.parse(await response.json()) + if (!tokens.refresh_token) throw new Error("Kimi Code OAuth did not return a refresh token") + return { + type: "kimi-code", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + tokenType: tokens.token_type, + } +} + +export async function refreshKimiCodeAccessToken(credentials: KimiCodeCredentials): Promise { + const response = await postForm(KIMI_CODE_OAUTH_CONFIG.tokenEndpoint, { + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: KIMI_CODE_OAUTH_CONFIG.clientId, + }) + if (!response.ok) throw await readOAuthError(response) + const tokens = tokenResponseSchema.parse(await response.json()) + return { + type: "kimi-code", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? credentials.refreshToken, + expiresAt: Date.now() + tokens.expires_in * 1000, + tokenType: tokens.token_type ?? credentials.tokenType, + } +} + +const delay = (milliseconds: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + const timeout = signal.aborted + ? undefined + : setTimeout(() => { + signal.removeEventListener("abort", onAbort) + resolve() + }, milliseconds) + const onAbort = () => { + if (timeout) clearTimeout(timeout) + reject(new Error("Kimi Code authorization was cancelled")) + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener("abort", onAbort, { once: true }) + }) + +export class KimiCodeOAuthManager { + private context: ExtensionContext | null = null + private credentials: KimiCodeCredentials | null = null + private state: KimiCodeOAuthState = { status: "idle" } + private refreshPromise: Promise | null = null + private pollingPromise: Promise | null = null + private pollingController: AbortController | null = null + private credentialsGeneration = 0 + private authorizationGeneration = 0 + + initialize(context: ExtensionContext): void { + this.context = context + } + + getState(): KimiCodeOAuthState { + return { ...this.state } + } + + private async loadCredentials(): Promise { + if (this.credentials) return this.credentials + const stored = await this.context?.secrets.get(KIMI_CODE_CREDENTIALS_KEY) + if (!stored) return null + try { + this.credentials = credentialsSchema.parse(JSON.parse(stored)) + return this.credentials + } catch { + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + return null + } + } + + private async saveCredentials( + credentials: KimiCodeCredentials, + isCurrent: () => boolean = () => true, + ): Promise { + if (!this.context) throw new Error("Kimi Code OAuth manager is not initialized") + if (!isCurrent()) return false + const serialized = JSON.stringify(credentials) + await this.context.secrets.store(KIMI_CODE_CREDENTIALS_KEY, serialized) + if (!isCurrent()) { + if ((await this.context.secrets.get(KIMI_CODE_CREDENTIALS_KEY)) === serialized) { + await this.context.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + } + return false + } + this.credentials = credentials + this.state = { status: "authenticated" } + return true + } + + async clearCredentials(): Promise { + this.credentialsGeneration++ + this.cancelAuthorization() + const refreshPromise = this.refreshPromise + if (refreshPromise) await refreshPromise.catch(() => null) + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + this.credentials = null + this.state = { status: "idle" } + } + + async isAuthenticated(): Promise { + return (await this.getAccessToken()) !== null + } + + async getAccessToken(forceRefresh = false): Promise { + const credentials = await this.loadCredentials() + if (!credentials) return null + if (!forceRefresh && Date.now() < credentials.expiresAt - TOKEN_EXPIRY_BUFFER_MS) { + return credentials.accessToken + } + if (!this.refreshPromise) { + const generation = this.credentialsGeneration + this.refreshPromise = refreshKimiCodeAccessToken(credentials) + .then(async (next) => { + if (!(await this.saveCredentials(next, () => generation === this.credentialsGeneration))) + return null + return next + }) + .catch(async (error) => { + if (error instanceof KimiCodeOAuthError && error.code === "invalid_grant") { + this.credentialsGeneration++ + await this.context?.secrets.delete(KIMI_CODE_CREDENTIALS_KEY) + this.credentials = null + this.state = { status: "idle" } + } + return null + }) + .finally(() => { + this.refreshPromise = null + }) + } + return (await this.refreshPromise)?.accessToken ?? null + } + + async forceRefreshAccessToken(): Promise { + return this.getAccessToken(true) + } + + async startAuthorization(): Promise { + this.cancelAuthorization() + const generation = ++this.authorizationGeneration + this.state = { status: "authorizing" } + const controller = new AbortController() + this.pollingController = controller + try { + const device = await requestDeviceAuthorization(controller.signal) + if (generation !== this.authorizationGeneration || this.pollingController !== controller) { + throw new Error("Kimi Code authorization was cancelled") + } + const expiresAt = Date.now() + device.expires_in * 1000 + const verificationUri = device.verification_uri_complete ?? device.verification_uri + this.state = { + status: "polling", + userCode: device.user_code, + verificationUri, + expiresAt, + } + this.pollingPromise = this.pollForToken( + device.device_code, + expiresAt, + device.interval ?? 5, + controller, + generation, + ) + return { + userCode: device.user_code, + verificationUri: device.verification_uri, + verificationUriComplete: device.verification_uri_complete, + expiresAt, + } + } catch (error) { + if (generation === this.authorizationGeneration && this.pollingController === controller) { + this.state = { status: "error", error: error instanceof Error ? error.message : String(error) } + this.pollingController = null + } + throw error + } + } + + waitForAuthorization(): Promise { + if (!this.pollingPromise) throw new Error("No Kimi Code authorization is in progress") + return this.pollingPromise + } + + cancelAuthorization(): void { + this.authorizationGeneration++ + this.pollingController?.abort() + this.pollingController = null + this.pollingPromise = null + if (this.state.status === "authorizing" || this.state.status === "polling") this.state = { status: "idle" } + } + + private async pollForToken( + deviceCode: string, + expiresAt: number, + initialIntervalSeconds: number, + controller: AbortController, + generation: number, + ): Promise { + let intervalSeconds = initialIntervalSeconds + try { + while (Date.now() < expiresAt) { + await delay(intervalSeconds * 1000, controller.signal) + try { + const credentials = await requestDeviceToken(deviceCode, controller.signal) + if (generation !== this.authorizationGeneration || this.pollingController !== controller) { + throw new Error("Kimi Code authorization was cancelled") + } + const saved = await this.saveCredentials( + credentials, + () => generation === this.authorizationGeneration && this.pollingController === controller, + ) + if (!saved) throw new Error("Kimi Code authorization was cancelled") + return credentials + } catch (error) { + if (error instanceof KimiCodeOAuthError && error.code === "authorization_pending") continue + if (error instanceof KimiCodeOAuthError && error.code === "slow_down") { + intervalSeconds += 5 + continue + } + throw error + } + } + throw new Error("Kimi Code authorization expired") + } catch (error) { + if ( + !controller.signal.aborted && + generation === this.authorizationGeneration && + this.pollingController === controller + ) { + this.state = { status: "error", error: error instanceof Error ? error.message : String(error) } + } + throw error + } finally { + if (generation === this.authorizationGeneration && this.pollingController === controller) { + this.pollingController = null + this.pollingPromise = null + } + } + } +} + +export const kimiCodeOAuthManager = new KimiCodeOAuthManager() diff --git a/src/package.json b/src/package.json index 7eb12d5fde..b4d55845b0 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "ZooCodeOrganization", - "version": "3.70.0", + "version": "3.72.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -11,7 +11,7 @@ }, "engines": { "vscode": "^1.100.0", - "node": "20.20.2" + "node": "22.23.1" }, "author": { "name": "Zoo Code" @@ -461,6 +461,7 @@ "@anthropic-ai/vertex-sdk": "^0.19.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", "@aws-sdk/credential-providers": "^3.922.0", + "@smithy/node-http-handler": "^4.0.0", "@google/genai": "^1.29.1", "@lmstudio/sdk": "^1.1.1", "@mistralai/mistralai": "^1.9.18", @@ -486,6 +487,8 @@ "get-folder-size": "^5.0.0", "global-agent": "^3.0.0", "google-auth-library": "^10.2.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", "gray-matter": "^4.0.3", "i18next": "^25.0.0", "ignore": "^7.0.3", @@ -532,17 +535,16 @@ "@types/clone-deep": "4.0.4", "@types/diff": "5.2.3", "@types/lodash.debounce": "4.0.9", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/node-cache": "4.2.5", "@types/proper-lockfile": "4.1.4", "@types/ps-tree": "1.1.6", "@types/semver-compare": "1.0.3", - "@types/shell-quote": "1.7.5", "@types/vscode": "1.100.0", "@vitest/coverage-v8": "4.1.9", "@vscode/vsce": "3.9.2", "ai": "6.0.218", - "esbuild-wasm": "0.25.12", + "esbuild-wasm": "0.28.1", "execa": "9.6.1", "mkdirp": "3.0.1", "nock": "14.0.15", diff --git a/src/services/code-index/processors/__tests__/parser.spec.ts b/src/services/code-index/processors/__tests__/parser.spec.ts index 68b523c7df..1c8154e03f 100644 --- a/src/services/code-index/processors/__tests__/parser.spec.ts +++ b/src/services/code-index/processors/__tests__/parser.spec.ts @@ -267,6 +267,31 @@ describe("CodeParser", () => { }) describe("_chunkTextByLines", () => { + it("should not emit a chunk whose segment hash has already been seen", () => { + const lines = ["Fallback content long enough to produce a chunk without being filtered out."] + const seenSegmentHashes = new Set() + + const firstResult = parser["_chunkTextByLines"]( + lines, + "manual.txt", + "hash", + "fallback_chunk", + seenSegmentHashes, + ) + const duplicateResult = parser["_chunkTextByLines"]( + lines, + "manual.txt", + "hash", + "fallback_chunk", + seenSegmentHashes, + ) + + expect(firstResult).toHaveLength(1) + expect(firstResult[0].segmentHash).toMatch(/^[a-f0-9]{64}$/) + expect(seenSegmentHashes).toEqual(new Set([firstResult[0].segmentHash])) + expect(duplicateResult).toEqual([]) + }) + it("should handle oversized lines by splitting them", async () => { const longLine = "a".repeat(2000) const lines = ["normal", longLine, "normal"] diff --git a/src/services/code-index/processors/__tests__/parser.txt.spec.ts b/src/services/code-index/processors/__tests__/parser.txt.spec.ts new file mode 100644 index 0000000000..0fa9526916 --- /dev/null +++ b/src/services/code-index/processors/__tests__/parser.txt.spec.ts @@ -0,0 +1,58 @@ +import { CodeParser } from "../parser" +import { scannerExtensions, shouldUseFallbackChunking } from "../../shared/supported-extensions" + +vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + +describe("CodeParser - plain text support", () => { + it("supports .txt files through fallback chunking", async () => { + expect(scannerExtensions).toContain(".txt") + expect(shouldUseFallbackChunking(".txt")).toBe(true) + + const content = [ + "Zoo Code plain text indexing regression test.", + "This sentence contains searchable content that only exists in the text file.", + "The fallback parser should preserve every line while creating an indexable chunk.", + ].join("\n") + + const blocks = await new CodeParser().parseFile("manual.txt", { + content, + fileHash: "txt-file-hash", + }) + + expect(blocks).toHaveLength(1) + expect(blocks[0]).toMatchObject({ + file_path: "manual.txt", + type: "fallback_chunk", + start_line: 1, + end_line: 3, + content, + fileHash: "txt-file-hash", + segmentHash: expect.any(String), + }) + }) + + it("parses uppercase .TXT extensions", async () => { + expect(shouldUseFallbackChunking(".TXT")).toBe(true) + + const content = "Uppercase plain-text extension content long enough to produce a fallback chunk." + const blocks = await new CodeParser().parseFile("manual.TXT", { + content, + fileHash: "uppercase-txt-file-hash", + }) + + expect(blocks).toHaveLength(1) + expect(blocks[0]).toMatchObject({ + file_path: "manual.TXT", + type: "fallback_chunk", + content, + fileHash: "uppercase-txt-file-hash", + segmentHash: expect.any(String), + }) + }) +}) diff --git a/src/services/code-index/processors/parser.ts b/src/services/code-index/processors/parser.ts index 8611884ade..8445ea1971 100644 --- a/src/services/code-index/processors/parser.ts +++ b/src/services/code-index/processors/parser.ts @@ -45,7 +45,7 @@ export class CodeParser implements ICodeParser { let content: string let fileHash: string - if (options?.content) { + if (options?.content !== undefined) { content = options.content fileHash = options.fileHash || this.createFileHash(content) } else { diff --git a/src/services/code-index/shared/supported-extensions.ts b/src/services/code-index/shared/supported-extensions.ts index 80dd7102ff..a5f93e161f 100644 --- a/src/services/code-index/shared/supported-extensions.ts +++ b/src/services/code-index/shared/supported-extensions.ts @@ -1,4 +1,7 @@ import { extensions as allExtensions } from "../../tree-sitter" +import { fallbackExtensions, isFallbackExtension } from "../../shared/fallback-extensions" + +export { fallbackExtensions } // Include all extensions including markdown for the scanner export const scannerExtensions = allExtensions @@ -18,17 +21,11 @@ export const scannerExtensions = allExtensions * * Note: Do NOT remove parser cases from languageParser.ts as they may be used elsewhere */ -export const fallbackExtensions = [ - ".vb", // Visual Basic .NET - no dedicated WASM parser - ".scala", // Scala - uses fallback chunking instead of Lua query workaround - ".swift", // Swift - uses fallback chunking due to parser instability -] - /** * Check if a file extension should use fallback chunking * @param extension File extension (including the dot) * @returns true if the extension should use fallback chunking */ export function shouldUseFallbackChunking(extension: string): boolean { - return fallbackExtensions.includes(extension.toLowerCase()) + return isFallbackExtension(extension) } diff --git a/src/services/ripgrep/__tests__/index.spec.ts b/src/services/ripgrep/__tests__/index.spec.ts index e6a613ad67..defc6d23b2 100644 --- a/src/services/ripgrep/__tests__/index.spec.ts +++ b/src/services/ripgrep/__tests__/index.spec.ts @@ -95,4 +95,50 @@ describe("getBinPath", () => { expect(await getBinPath(appRoot)).toBeUndefined() }) + + // Regression test for https://github.com/Zoo-Code-Org/Zoo-Code/issues/1024 + // VS Code 1.130+ ships @vscode/ripgrep >=1.18, where the binary lives in a + // platform-specific optional package (e.g. @vscode/ripgrep-win32-x64). + // None of the previous candidate paths matched this layout. + it("resolves ripgrep from the @vscode/ripgrep >=1.18 platform-package layout", async () => { + const arch = process.env.npm_config_arch || process.arch + const rg = path.join(appRoot, `node_modules/@vscode/ripgrep-${process.platform}-${arch}/bin`, binName) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + }) + + it("resolves ripgrep from the unpacked @vscode/ripgrep >=1.18 platform-package layout", async () => { + const arch = process.env.npm_config_arch || process.arch + const rg = path.join( + appRoot, + `node_modules.asar.unpacked/@vscode/ripgrep-${process.platform}-${arch}/bin`, + binName, + ) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + }) + + it("respects npm_config_arch when selecting the platform package", async () => { + const overrideArch = "x64" + const original = process.env.npm_config_arch + process.env.npm_config_arch = overrideArch + try { + const rg = path.join( + appRoot, + `node_modules/@vscode/ripgrep-${process.platform}-${overrideArch}/bin`, + binName, + ) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + } finally { + if (original === undefined) { + delete process.env.npm_config_arch + } else { + process.env.npm_config_arch = original + } + } + }) }) diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index 96f5c34e4b..0d4a25056b 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -90,6 +90,9 @@ export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): * resolution) and the diagnostic command (existence report for all paths). */ export function ripgrepCandidatePaths(vscodeAppRoot: string): readonly string[] { + // Read at call time so process.env.npm_config_arch overrides take effect, + // matching @vscode/ripgrep's own arch selection logic. + const platformPkg = `@vscode/ripgrep-${process.platform}-${process.env.npm_config_arch || process.arch}` return [ path.join(vscodeAppRoot, "node_modules/@vscode/ripgrep/bin/", binName), path.join(vscodeAppRoot, "node_modules/vscode-ripgrep/bin", binName), @@ -101,15 +104,18 @@ export function ripgrepCandidatePaths(vscodeAppRoot: string): readonly string[] `node_modules.asar.unpacked/@vscode/ripgrep-universal/${ripgrepUniversalBinDir}`, binName, ), + // @vscode/ripgrep >=1.18 (VS Code 1.130+): binary lives in a platform-specific optional package. + path.join(vscodeAppRoot, `node_modules/${platformPkg}/bin`, binName), + path.join(vscodeAppRoot, `node_modules.asar.unpacked/${platformPkg}/bin`, binName), ] } /** * Get the path to the ripgrep binary shipped inside the VS Code installation. * - * Both the long-standing `@vscode/ripgrep` layout and the newer - * `@vscode/ripgrep-universal` layout are checked — the latter is what VS Code - * Insiders' staged-install builds use (see microsoft/vscode#252063). + * Probes all known layouts: classic @vscode/ripgrep, @vscode/ripgrep-universal + * (VS Code Insiders staged-install), and the @vscode/ripgrep >=1.18 + * platform-package layout used by VS Code 1.130+. * * Returns `undefined` when ripgrep cannot be located. */ diff --git a/src/services/shared/fallback-extensions.ts b/src/services/shared/fallback-extensions.ts new file mode 100644 index 0000000000..ee78b61b0f --- /dev/null +++ b/src/services/shared/fallback-extensions.ts @@ -0,0 +1,29 @@ +/** + * Extensions that should not be parsed for structural definitions and should + * instead use line-based fallback chunking where indexing is supported. + */ +export const fallbackExtensions = [".txt", ".vb", ".scala", ".swift"] as const + +/** + * Fallback extensions that do not have a structural parser. Scala and Swift + * still support structural parsing outside code indexing. + */ +export const nonStructuralExtensions = [".txt", ".vb"] as const + +/** + * Check whether a file extension should bypass structural parsing. + * + * @param extension File extension, including the leading dot + */ +export function isFallbackExtension(extension: string): boolean { + return (fallbackExtensions as readonly string[]).includes(extension.toLowerCase()) +} + +/** + * Check whether a file extension should bypass structural parsing entirely. + * + * @param extension File extension, including the leading dot + */ +export function isNonStructuralExtension(extension: string): boolean { + return (nonStructuralExtensions as readonly string[]).includes(extension.toLowerCase()) +} diff --git a/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts new file mode 100644 index 0000000000..d09f12ac35 --- /dev/null +++ b/src/services/tree-sitter/__tests__/fixtures/sample-dart.ts @@ -0,0 +1,113 @@ +export default `abstract class Animal { + String speak(); + + Future describe({ + required bool verbose, + }); +} + +class Point { + const Point(); + Point.named(); + factory Point.origin() => const Point(); + + Point.fromCoordinates( + int x, + int y, + ) : assert(x >= 0), + assert(y >= 0); + + factory Point.fromRecord( + ({int x, int y}) coordinates, + ) => Point.fromCoordinates( + coordinates.x, + coordinates.y, + ); + + int get x => 0; + set x(int value) {} + + Point operator +(Point other) => this; + + Point operator [](int index) => this; + + static List emptyList() { + return []; + } +} + +mixin Runner { + void run() { + print("running"); + } +} + +enum Status { + ready, + running; + + const Status(); +} + +class Dog extends Animal with Runner { + final String name; + + Dog(this.name); + + @override + String speak() { + return "\$name barks"; + } +} + +extension StringTools on String { + String doubled() { + return this + this; + } +} + +extension on int { + int squared() => this * this; +} + +extension type UserId(int value) { + UserId.zero() : value = 0; +} + +typedef Operation = int Function(int left, int right); + +typedef AsyncOperation = Future Function( + T value, { + required Duration timeout, +}); + +int get answer => 42; +set answer(int value) {} + +int add(int left, int right) { + return left + right; +} + +Future retry( + Future Function() operation, { + int attempts = 3, +}) async { + return operation(); +} + +Future initialize() async { + await Future.value(); +} + +Iterable countUpTo(int maximum) sync* { + for (var value = 0; value <= maximum; value++) { + yield value; + } +} + +Stream countPeriodically(int maximum) async* { + for (var value = 0; value <= maximum; value++) { + yield value; + } +} +` diff --git a/src/services/tree-sitter/__tests__/languageParser.spec.ts b/src/services/tree-sitter/__tests__/languageParser.spec.ts index 9bf9ecc881..fe4dcdb2d9 100644 --- a/src/services/tree-sitter/__tests__/languageParser.spec.ts +++ b/src/services/tree-sitter/__tests__/languageParser.spec.ts @@ -49,6 +49,12 @@ describe("loadRequiredLanguageParsers", () => { expect(parsers.kts.query).toBeDefined() }) + it("should load Dart parser for .dart files", async () => { + const parsers = await loadRequiredLanguageParsers(["test.dart"], WASM_DIR) + expect(parsers.dart).toBeDefined() + expect(parsers.dart.query).toBeDefined() + }) + it("should throw error for unsupported file extensions", async () => { const files = ["test.unsupported"] await expect(loadRequiredLanguageParsers(files, WASM_DIR)).rejects.toThrow("Unsupported language: unsupported") diff --git a/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts new file mode 100644 index 0000000000..d7d681a263 --- /dev/null +++ b/src/services/tree-sitter/__tests__/parseSourceCodeDefinitions.dart.spec.ts @@ -0,0 +1,123 @@ +import { dartQuery } from "../queries" +import { testParseSourceCodeDefinitions } from "./helpers" +import sampleDartContent from "./fixtures/sample-dart" + +const dartOptions = { + language: "dart", + wasmFile: "tree-sitter-dart.wasm", + queryString: dartQuery, + extKey: "dart", +} + +describe("parseSourceCodeDefinitionsForFile with Dart", () => { + it("captures a redirecting factory constructor", async () => { + const content = `class Logger { + factory Logger() = ConsoleLogger; + } + + class ConsoleLogger implements Logger {}` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \|\s*factory Logger\(\) = ConsoleLogger/) + }) + + it("captures a multiline generative constructor once", async () => { + const content = `class Point { + Point.fromCoordinates( + int x, + int y, + ); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result?.match(/\d+--\d+ \|\s*Point\.fromCoordinates\(/g)).toHaveLength(1) + }) + + it("captures a mixin application class", async () => { + const content = `class Base {} + mixin Serializable {} + class SerializableBase = Base with Serializable;` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \|\s*class SerializableBase = Base with Serializable/) + }) + + it("captures external top-level functions and methods", async () => { + const content = `external int nativeVersion(); + + class NativeApi { + external String platformName(); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \| external int nativeVersion\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*external String platformName\(\)/) + }) + + it("does not report local functions as file-level definitions", async () => { + const content = `void outer() { + int inner() => 1; + print(inner()); + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + + expect(result).toMatch(/\d+--\d+ \| void outer\(\)/) + expect(result).not.toMatch(/int inner\(\)/) + }) + + it("includes a multiline top-level function body without duplicating its declaration", async () => { + const content = `int add(int left, int right) { + final result = left + right; + return result; + }` + + const result = await testParseSourceCodeDefinitions("/test/file.dart", content, dartOptions) + const addDefinitions = result?.split("\n").filter((line) => line.includes("int add(")) ?? [] + + expect(addDefinitions).toEqual(["1--4 | int add(int left, int right) {"]) + }) + + it("should capture common Dart declarations", async () => { + const result = await testParseSourceCodeDefinitions("/test/file.dart", sampleDartContent, dartOptions) + const definitionLines = result?.split("\n").filter((line) => line.includes(" | ")) ?? [] + + expect(result).toMatch(/\d+--\d+ \| abstract class Animal/) + expect(result).toMatch(/\d+--\d+ \|\s*Future describe/) + expect(result).toMatch(/\d+--\d+ \| class Point/) + expect(result).toMatch(/\d+--\d+ \|\s*const Point\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point\.named\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.origin\(\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point\.fromCoordinates\(/) + expect(result).toMatch(/\d+--\d+ \|\s*factory Point\.fromRecord\(/) + expect(result?.match(/\d+--\d+ \| Point\.fromCoordinates\(/g)).toHaveLength(1) + expect(result?.match(/\d+--\d+ \| factory Point\.fromRecord\(/g)).toHaveLength(1) + expect(result).toMatch(/\d+--\d+ \|\s*int get x/) + expect(result).toMatch(/\d+--\d+ \|\s*set x\(int value\)/) + expect(result).toMatch(/\d+--\d+ \|\s*Point operator \+/) + expect(result).toMatch(/\d+--\d+ \|\s*Point operator \[]/) + expect(result).toMatch(/\d+--\d+ \|\s*static List emptyList/) + expect(result).toMatch(/\d+--\d+ \| mixin Runner/) + expect(result).toMatch(/\d+--\d+ \| enum Status/) + expect(result).toMatch(/\d+--\d+ \|\s*const Status\(\)/) + expect(result).toMatch(/\d+--\d+ \| class Dog extends Animal with Runner/) + expect(result).toMatch(/\d+--\d+ \| extension StringTools on String/) + expect(result).toMatch(/\d+--\d+ \| extension on int/) + expect(result).toMatch(/\d+--\d+ \| extension type UserId/) + expect(result).toMatch(/\d+--\d+ \| typedef Operation/) + expect(result).toMatch(/\d+--\d+ \| typedef AsyncOperation/) + expect(result).toMatch(/\d+--\d+ \| int get answer/) + expect(result).toMatch(/\d+--\d+ \| set answer\(int value\)/) + expect(result).toMatch(/\d+--\d+ \|\s*String speak\(\)/) + expect(result).toMatch(/\d+--\d+ \| int add\(int left, int right\)/) + expect(result).toMatch(/\d+--\d+ \| Future retry/) + expect(result).toMatch(/\d+--\d+ \| Future initialize\(\) async/) + expect(result).toMatch(/\d+--\d+ \| Iterable countUpTo\(int maximum\) sync\*/) + expect(result).toMatch(/\d+--\d+ \| Stream countPeriodically\(int maximum\) async\*/) + expect(definitionLines).toHaveLength(37) + }) +}) diff --git a/src/services/tree-sitter/__tests__/plainTextIntegration.spec.ts b/src/services/tree-sitter/__tests__/plainTextIntegration.spec.ts new file mode 100644 index 0000000000..1635a40d04 --- /dev/null +++ b/src/services/tree-sitter/__tests__/plainTextIntegration.spec.ts @@ -0,0 +1,37 @@ +// Mocks must come first, before imports + +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../languageParser", () => ({ + loadRequiredLanguageParsers: vi.fn(), +})) + +import * as fs from "fs/promises" +import { loadRequiredLanguageParsers } from "../languageParser" +import { parseSourceCodeDefinitionsForFile } from "../index" + +describe("Non-structural Extension Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(["manual.txt", "legacy.vb"])( + "returns undefined for %s without loading a tree-sitter parser", + async (filePath) => { + const result = await parseSourceCodeDefinitionsForFile(filePath) + + expect(result).toBeUndefined() + }, + ) + + afterEach(() => { + expect(loadRequiredLanguageParsers).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index 3c124e5b74..241094699d 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -5,6 +5,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { parseMarkdown } from "./markdownParser" import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" import { QueryCapture } from "web-tree-sitter" +import { isNonStructuralExtension } from "../shared/fallback-extensions" // Private constant const DEFAULT_MIN_COMPONENT_LINES_VALUE = 4 @@ -66,6 +67,8 @@ const extensions = [ // Markdown "md", "markdown", + // Plain text + "txt", // JSON "json", // CSS @@ -90,6 +93,8 @@ const extensions = [ "erb", // Visual Basic .NET "vb", + // Dart + "dart", ].map((e) => `.${e}`) export { extensions } @@ -111,6 +116,11 @@ export async function parseSourceCodeDefinitionsForFile( return undefined } + // Files without a structural parser have no definitions to extract + if (isNonStructuralExtension(ext)) { + return undefined + } + // Special case for markdown files if (ext === ".md" || ext === ".markdown") { // Check if we have permission to access this file @@ -220,9 +230,14 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st const definitionNode = name.includes("name") ? node.parent : node if (!definitionNode) return + // Some grammars represent a definition's body as the captured signature's + // next sibling. Include that adjacent body in the definition range. + const trailingDefinitionBody = + definitionNode.nextSibling?.type === "function_body" ? definitionNode.nextSibling : undefined + // Get the start and end lines of the full definition const startLine = definitionNode.startPosition.row - const endLine = definitionNode.endPosition.row + const endLine = trailingDefinitionBody?.endPosition.row ?? definitionNode.endPosition.row const lineCount = endLine - startLine + 1 // Skip components that don't span enough lines @@ -262,9 +277,11 @@ function processCaptures(captures: QueryCapture[], lines: string[], language: st if (node.parent && node.parent.lastChild) { const contextEnd = node.parent.lastChild.endPosition.row const contextSpan = contextEnd - node.parent.startPosition.row + 1 + const hasDistinctContextStart = node.parent.startPosition.row !== startLine - // Only include context if it spans multiple lines - if (contextSpan >= getMinComponentLines()) { + // Only include context when it adds a distinct source line. A parent + // starting on the definition line would echo the same declaration. + if (hasDistinctContextStart && contextSpan >= getMinComponentLines()) { // Add the full range first const rangeKey = `${node.parent.startPosition.row}-${contextEnd}` if (!processedLines.has(rangeKey)) { diff --git a/src/services/tree-sitter/languageParser.ts b/src/services/tree-sitter/languageParser.ts index a8ac0a9ead..b7faf2cdba 100644 --- a/src/services/tree-sitter/languageParser.ts +++ b/src/services/tree-sitter/languageParser.ts @@ -28,6 +28,7 @@ import { embeddedTemplateQuery, elispQuery, elixirQuery, + dartQuery, } from "./queries" export interface LanguageParser { @@ -218,6 +219,10 @@ export async function loadRequiredLanguageParsers(filesToParse: string[], source language = await loadLanguage("elixir", sourceDirectory) query = new Query(language, elixirQuery) break + case "dart": + language = await loadLanguage("dart", sourceDirectory) + query = new Query(language, dartQuery) + break default: throw new Error(`Unsupported language: ${ext}`) } diff --git a/src/services/tree-sitter/queries/dart.ts b/src/services/tree-sitter/queries/dart.ts new file mode 100644 index 0000000000..d4c67a05ac --- /dev/null +++ b/src/services/tree-sitter/queries/dart.ts @@ -0,0 +1,53 @@ +// Definition captures adapted from tree-sitter-dart's canonical tags query: +// https://github.com/UserNobody14/tree-sitter-dart/blob/master/queries/tags.scm +export default ` +(class_definition + name: (identifier) @name) @definition.class + +(class_definition + (mixin_application_class + (identifier) @name)) @definition.class + +(type_alias + (type_identifier) @name) @definition.type + +(declaration + (function_signature + name: (identifier) @name)) @definition.method + +(redirecting_factory_constructor_signature + (identifier) @name) @definition.method + +(method_signature) @definition.method + +(constructor_signature + name: (identifier) @name) @definition.method + +(constant_constructor_signature + (identifier) @name) @definition.method + +(mixin_declaration + (mixin) + (identifier) @name) @definition.mixin + +(extension_declaration + name: (identifier) @name) @definition.extension + +(extension_type_declaration + name: (identifier) @name) @definition.extension + +(enum_declaration + name: (identifier) @name) @definition.enum + +(program + (getter_signature + name: (identifier) @name) @definition.function) + +(program + (setter_signature + name: (identifier) @name) @definition.function) + +(program + (function_signature + name: (identifier) @name) @definition.function) +` diff --git a/src/services/tree-sitter/queries/index.ts b/src/services/tree-sitter/queries/index.ts index de9b9cafa3..0faa256269 100644 --- a/src/services/tree-sitter/queries/index.ts +++ b/src/services/tree-sitter/queries/index.ts @@ -26,3 +26,4 @@ export { zigQuery } from "./zig" export { default as embeddedTemplateQuery } from "./embedded_template" export { elispQuery } from "./elisp" export { scalaQuery } from "./scala" +export { default as dartQuery } from "./dart" diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index bb56718a7d..923582bd06 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -1,4 +1,4 @@ -import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" export class ProfileValidator { public static isProfileAllowed(profile: ProviderSettings, allowList: OrganizationAllowList): boolean { @@ -51,36 +51,36 @@ export class ProfileValidator { private static getModelIdFromProfile(profile: ProviderSettings): string | undefined { switch (profile.apiProvider) { - case "openai": + case providerIdentifiers.openai: return profile.openAiModelId - case "anthropic": - case "openai-native": - case "bedrock": - case "vertex": - case "gemini": - case "mistral": - case "deepseek": - case "xai": - case "sambanova": - case "fireworks": - case "friendli": + case providerIdentifiers.anthropic: + case providerIdentifiers.openaiNative: + case providerIdentifiers.bedrock: + case providerIdentifiers.vertex: + case providerIdentifiers.gemini: + case providerIdentifiers.mistral: + case providerIdentifiers.deepseek: + case providerIdentifiers.xai: + case providerIdentifiers.sambanova: + case providerIdentifiers.fireworks: + case providerIdentifiers.friendli: return profile.apiModelId - case "litellm": + case providerIdentifiers.litellm: return profile.litellmModelId - case "lmstudio": + case providerIdentifiers.lmstudio: return profile.lmStudioModelId - case "vscode-lm": + case providerIdentifiers.vscodeLm: // We probably need something more flexible for this one, if we need to really support it here. return profile.vsCodeLmModelSelector?.id - case "openrouter": + case providerIdentifiers.openrouter: return profile.openRouterModelId - case "ollama": + case providerIdentifiers.ollama: return profile.ollamaModelId - case "requesty": + case providerIdentifiers.requesty: return profile.requestyModelId - case "unbound": + case providerIdentifiers.unbound: return profile.unboundModelId - case "fake-ai": + case providerIdentifiers.fakeAi: default: return undefined } diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index efeb7a4820..ace96c9fd0 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -1,11 +1,78 @@ // npx vitest run src/shared/__tests__/ProfileValidator.spec.ts -import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" import { ProfileValidator } from "../ProfileValidator" describe("ProfileValidator", () => { describe("isProfileAllowed", () => { + it.each([ + ["openai", { openAiModelId: "model" }], + ["anthropic", { apiModelId: "model" }], + ["openaiNative", { apiModelId: "model" }], + ["bedrock", { apiModelId: "model" }], + ["vertex", { apiModelId: "model" }], + ["gemini", { apiModelId: "model" }], + ["mistral", { apiModelId: "model" }], + ["deepseek", { apiModelId: "model" }], + ["xai", { apiModelId: "model" }], + ["sambanova", { apiModelId: "model" }], + ["fireworks", { apiModelId: "model" }], + ["friendli", { apiModelId: "model" }], + ["litellm", { litellmModelId: "model" }], + ["lmstudio", { lmStudioModelId: "model" }], + ["vscodeLm", { vsCodeLmModelSelector: { id: "model" } }], + ["openrouter", { openRouterModelId: "model" }], + ["ollama", { ollamaModelId: "model" }], + ["requesty", { requestyModelId: "model" }], + ["unbound", { unboundModelId: "model" }], + ])("resolves %s model fields through canonical identifiers", (identifierKey, profileSettings) => { + const canonicalIdentifier = providerIdentifiers[identifierKey as keyof typeof providerIdentifiers] + const modelId = "model" + + const profile = { + apiProvider: canonicalIdentifier, + ...profileSettings, + } as ProviderSettings + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [canonicalIdentifier]: { allowAll: false, models: [modelId] }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) + + const negativeAllowList: OrganizationAllowList = { + allowAll: false, + providers: { + [canonicalIdentifier]: { allowAll: false, models: ["other-model"] }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, negativeAllowList)).toBe(false) + }) + + it.each([ + { providerAllowAll: false, expected: false }, + { providerAllowAll: true, expected: true }, + ])( + "preserves missing-model fallback behavior when provider allowAll is $providerAllowAll", + ({ providerAllowAll, expected }) => { + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + } + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.openai]: { allowAll: providerAllowAll }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(expected) + }, + ) + it("should allow any profile when allowAll is true", () => { const allowList: OrganizationAllowList = { allowAll: true, @@ -168,17 +235,17 @@ describe("ProfileValidator", () => { // Test specific providers that use apiModelId const apiModelProviders = [ - "anthropic", - "openai-native", - "bedrock", - "vertex", - "gemini", - "mistral", - "deepseek", - "xai", - "sambanova", - "fireworks", - "friendli", + providerIdentifiers.anthropic, + providerIdentifiers.openaiNative, + providerIdentifiers.bedrock, + providerIdentifiers.vertex, + providerIdentifiers.gemini, + providerIdentifiers.mistral, + providerIdentifiers.deepseek, + providerIdentifiers.xai, + providerIdentifiers.sambanova, + providerIdentifiers.fireworks, + providerIdentifiers.friendli, ] apiModelProviders.forEach((provider) => { @@ -190,7 +257,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: provider as any, // Type assertion needed here + apiProvider: provider, apiModelId: "test-model", } @@ -203,11 +270,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - litellm: { allowAll: false, models: ["test-model"] }, + [providerIdentifiers.litellm]: { allowAll: false, models: ["test-model"] }, }, } const profile: ProviderSettings = { - apiProvider: "litellm" as any, + apiProvider: providerIdentifiers.litellm, litellmModelId: "test-model", } @@ -218,11 +285,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - "vscode-lm": { allowAll: false, models: ["copilot-gpt-3.5"] }, + [providerIdentifiers.vscodeLm]: { allowAll: false, models: ["copilot-gpt-3.5"] }, }, } const profile: ProviderSettings = { - apiProvider: "vscode-lm", + apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector: { id: "copilot-gpt-3.5" }, } @@ -278,11 +345,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - "fake-ai": { allowAll: false }, + [providerIdentifiers.fakeAi]: { allowAll: false }, }, } const profile: ProviderSettings = { - apiProvider: "fake-ai", + apiProvider: providerIdentifiers.fakeAi, } expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(false) diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index f4fb8330bb..0363c27cdf 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -189,6 +189,33 @@ describe("getModelMaxOutputTokens", () => { ).toBe(32_768) }) + test("should preserve Anthropic hybrid token handling for Claude Opus 5", () => { + const model: ModelInfo = { + contextWindow: 1_000_000, + supportsPromptCache: true, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + maxTokens: 128_000, + } + + expect( + getModelMaxOutputTokens({ + modelId: "claude-opus-5", + model, + settings: { apiProvider: "anthropic", enableReasoningEffort: false }, + }), + ).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) + + expect( + getModelMaxOutputTokens({ + modelId: "claude-opus-5", + model, + settings: { apiProvider: "anthropic", enableReasoningEffort: true, modelMaxTokens: 32_768 }, + }), + ).toBe(32_768) + }) + test("should return model.maxTokens for non-Anthropic models that support reasoning budget but aren't using it", () => { const geminiModelId = "gemini-2.5-flash-preview-04-17" const model: ModelInfo = { diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 4ffb394eb2..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) }) @@ -86,4 +90,76 @@ describe("checkExistKey", () => { } expect(checkExistKey(config)).toBe(false) }) + + it("should return true for kimi-code provider with OAuth auth method", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "oauth", + } + 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", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return true for kimi-code provider with api-key auth and key present", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "test-key", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return false for kimi-code provider with api-key auth but no key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + } + expect(checkExistKey(config)).toBe(false) + }) + + it("should return false for zoo-gateway without session token or auth", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "alibaba/qwen-3.6-max-preview", + } + expect(checkExistKey(config)).toBe(false) + 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", + zooSessionToken: "zoo_ext_test_token", + } + expect(checkExistKey(config)).toBe(true) + }) + + it("should return true for zoo-gateway when Zoo Code session auth is active", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "alibaba/qwen-3.6-max-preview", + } + expect(checkExistKey(config, true)).toBe(true) + }) + + it("should ignore zooCodeIsAuthenticated for non-zoo-gateway providers", () => { + const config: ProviderSettings = { + apiProvider: "openrouter", + } + expect(checkExistKey(config, true)).toBe(false) + }) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index d5a579be9d..056612f9f9 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -186,8 +186,10 @@ const dynamicProviderExtras = { lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type poe: {} as { apiKey?: string; baseUrl?: string }, deepseek: {} as { apiKey?: string; baseUrl?: string }, + moonshot: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, + "kimi-code": {} as { apiKey?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 357144c168..3560199b91 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -1,15 +1,38 @@ -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" -export function checkExistKey(config: ProviderSettings | undefined) { +/** + * Returns whether a provider profile is sufficiently configured to leave the + * welcome/setup gate. + * + * `zooCodeIsAuthenticated` is needed for Zoo Gateway: auth lives in global + * secret storage (`zoo-code-auth`), and `zooSessionToken` is not part of + * `SECRET_STATE_KEYS`, so session-auth alone would otherwise look unconfigured. + */ +export function checkExistKey(config: ProviderSettings | undefined, zooCodeIsAuthenticated?: boolean) { if (!config) { return false } // 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 === 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 === providerIdentifiers.zooGateway) { + return Boolean(config.zooSessionToken) || Boolean(zooCodeIsAuthenticated) + } + // Check all secret keys from the centralized SECRET_STATE_KEYS array. // Filter out keys that are not part of ProviderSettings (global secrets are stored separately) const providerSecretKeys = SECRET_STATE_KEYS.filter((key) => !GLOBAL_SECRET_KEYS.includes(key as any)) 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/src/utils/__tests__/networkProxy.spec.ts b/src/utils/__tests__/networkProxy.spec.ts index 97c046d1b0..94e91cd990 100644 --- a/src/utils/__tests__/networkProxy.spec.ts +++ b/src/utils/__tests__/networkProxy.spec.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode } from "../networkProxy" +import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode, getSystemProxyUrl } from "../networkProxy" // Mock global-agent vi.mock("global-agent", () => ({ @@ -305,4 +305,168 @@ describe("networkProxy", () => { expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() }) }) + + describe("getSystemProxyUrl", () => { + beforeEach(() => { + vi.clearAllMocks() + // Clear all proxy env vars and VS Code setting before each test + delete process.env.HTTPS_PROXY + delete process.env.https_proxy + delete process.env.HTTP_PROXY + delete process.env.http_proxy + delete process.env.NO_PROXY + delete process.env.no_proxy + mockConfig.get.mockReturnValue(undefined) + }) + + it("should return proxy from HTTPS_PROXY env var", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should return proxy from https_proxy env var when HTTPS_PROXY not set", () => { + process.env.https_proxy = "http://proxy.corp:3128" + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should return proxy from HTTP_PROXY env var as fallback", () => { + process.env.HTTP_PROXY = "http://proxy.corp:8080" + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:8080") + }) + + it("should return trimmed proxy from VS Code setting", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "proxy") return " http://proxy.corp:3128 " + return "" + }) + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should return undefined when no proxy configured", () => { + mockConfig.get.mockReturnValue(undefined) + const result = getSystemProxyUrl() + expect(result).toBeUndefined() + }) + + it("should handle VS Code API errors gracefully", () => { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation(() => { + throw new Error("VS Code API unavailable") + }) + const result = getSystemProxyUrl() + expect(result).toBeUndefined() + }) + + it("should not return empty string proxy", () => { + mockConfig.get.mockReturnValue("") + const result = getSystemProxyUrl() + expect(result).toBeUndefined() + }) + + it("should prioritize env vars over VS Code settings", () => { + process.env.HTTPS_PROXY = "http://env-proxy:3128" + mockConfig.get.mockReturnValue("http://vscode-proxy:8080") + const result = getSystemProxyUrl() + expect(result).toBe("http://env-proxy:3128") + }) + + it("should trim whitespace from env var proxy values", () => { + process.env.HTTPS_PROXY = " http://proxy.corp:3128 " + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should reject whitespace-only proxy values", () => { + process.env.HTTPS_PROXY = " " + const result = getSystemProxyUrl() + expect(result).toBeUndefined() + }) + + it("should skip empty env var and try next fallback", () => { + process.env.HTTP_PROXY = " http://http-proxy:3128 " + const result = getSystemProxyUrl() + expect(result).toBe("http://http-proxy:3128") + }) + + it("should use VS Code setting when all env vars are empty", () => { + mockConfig.get.mockReturnValue(" http://vscode-proxy:8080 ") + const result = getSystemProxyUrl() + expect(result).toBe("http://vscode-proxy:8080") + }) + + describe("NO_PROXY handling", () => { + it("should bypass proxy when NO_PROXY exactly matches the target host", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "bedrock.vpce.internal" + const result = getSystemProxyUrl("https://bedrock.vpce.internal") + expect(result).toBeUndefined() + }) + + it("should bypass proxy when NO_PROXY is a domain suffix of the target host", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "amazonaws.com" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBeUndefined() + }) + + it("should bypass proxy for all hosts when NO_PROXY is '*'", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "*" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBeUndefined() + }) + + it("should use the proxy when NO_PROXY does not match the target host", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "example.com" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should handle leading dot and trailing port in NO_PROXY entries", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = ".amazonaws.com:443" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBeUndefined() + }) + + it("should match one entry out of a comma-separated NO_PROXY list", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "example.com, amazonaws.com, other.net" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBeUndefined() + }) + + it("should use the proxy when no entry in a comma-separated NO_PROXY list matches", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "example.com, foo.net, other.org" + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should also bypass the VS Code proxy when NO_PROXY matches", () => { + process.env.NO_PROXY = "amazonaws.com" + mockConfig.get.mockReturnValue("http://vscode-proxy:8080") + const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com") + expect(result).toBeUndefined() + }) + + it("should ignore NO_PROXY when no target URL is provided", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "*" + const result = getSystemProxyUrl() + expect(result).toBe("http://proxy.corp:3128") + }) + + it("should not bypass when target URL is malformed", () => { + process.env.HTTPS_PROXY = "http://proxy.corp:3128" + process.env.NO_PROXY = "amazonaws.com" + const result = getSystemProxyUrl("not-a-valid-url") + expect(result).toBe("http://proxy.corp:3128") + }) + }) + }) }) diff --git a/src/utils/networkProxy.ts b/src/utils/networkProxy.ts index 448bc1b576..835887334e 100644 --- a/src/utils/networkProxy.ts +++ b/src/utils/networkProxy.ts @@ -346,6 +346,79 @@ export function isDebugMode(): boolean { return extensionContext.extensionMode === vscode.ExtensionMode.Development } +/** + * Determine whether a target URL should bypass the proxy based on NO_PROXY / no_proxy. + * + * Follows the widely-used convention (curl, proxy-from-env): + * - `NO_PROXY=*` bypasses the proxy for every host. + * - Entries are comma-separated host suffixes; an optional leading `.` or `*.` and a + * trailing `:port` are ignored. A target matches when its hostname equals an entry + * or ends with `.` (so `amazonaws.com` covers `bedrock-runtime.us-east-1.amazonaws.com`). + * - Matching is case-insensitive. + */ +function isNoProxyHost(targetUrl: string): boolean { + const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "").trim() + if (!noProxy) return false + if (noProxy === "*") return true + + let hostname: string + try { + hostname = new URL(targetUrl).hostname.toLowerCase() + } catch { + return false + } + if (!hostname) return false + + return noProxy + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .some((entry) => { + // Normalize a leading wildcard/dot and a trailing port: "*.example.com:443" -> "example.com" + const suffix = entry.replace(/^\*?\./, "").replace(/:\d+$/, "") + if (!suffix) return false + return hostname === suffix || hostname.endsWith(`.${suffix}`) + }) +} + +/** + * Get the proxy URL from environment variables or VS Code settings. + * Works in all extension modes (production and debug). + * + * When `targetUrl` is provided and its host is covered by NO_PROXY / no_proxy, this + * returns undefined so the caller connects directly — honoring the user's intent to + * bypass the proxy for e.g. a directly-reachable VPC or AWS endpoint. + * + * @param targetUrl Optional URL of the request destination, used for NO_PROXY matching. + * @returns The proxy URL, or undefined if no proxy applies or only whitespace is set. + */ +export function getSystemProxyUrl(targetUrl?: string): string | undefined { + // If the destination is covered by NO_PROXY, connect directly regardless of proxy source. + if (targetUrl && isNoProxyHost(targetUrl)) { + return undefined + } + + // Standard proxy environment variables (HTTPS takes precedence over HTTP). + // Trim to reject whitespace-only values which would be invalid for HttpsProxyAgent. + const fromEnv = + process.env.HTTPS_PROXY?.trim() || + process.env.https_proxy?.trim() || + process.env.HTTP_PROXY?.trim() || + process.env.http_proxy?.trim() + + if (fromEnv) return fromEnv + + // Fall back to VS Code's http.proxy setting + try { + const vsCodeProxy = vscode.workspace.getConfiguration("http").get("proxy") + if (vsCodeProxy?.trim()) return vsCodeProxy.trim() + } catch { + // VS Code API may be unavailable in test environments + } + + return undefined +} + /** * Log a message to the output channel if available. */ diff --git a/webview-ui/.gitignore b/webview-ui/.gitignore index 1f81cba3f5..6d6c359019 100644 --- a/webview-ui/.gitignore +++ b/webview-ui/.gitignore @@ -7,6 +7,12 @@ # testing /coverage +/coverage-ct +/monocart-report +/assets/monocart-coverage-app.js +/playwright-report +/test-results +/playwright/.cache # production /build diff --git a/webview-ui/AGENTS.md b/webview-ui/AGENTS.md index adab48e9e0..c118e36c9a 100644 --- a/webview-ui/AGENTS.md +++ b/webview-ui/AGENTS.md @@ -2,7 +2,71 @@ This file provides guidance to agents working in `webview-ui/`. +## Testing Strategy Overview + +We use a complementary two-layer strategy for testing webview UI code: + +1. **Vitest + JSDOM (`*.test.tsx`)**: Unit, hook, state-machine, and interaction tests. +2. **Playwright Component Testing (`*.visual.tsx`)**: Visual snapshot, VS Code theme variable, layout, and shadow DOM tests. + +--- + +### When to write a JSDOM Test (`*.test.tsx`) vs. a Playwright Visual Test (`*.visual.tsx`) + +| Testing Goal | Recommended Harness | +| :-------------------------------------------------------------------- | :-------------------------------------------------------------------- | +| Component state transitions, reducer actions, custom hook behavior | **Vitest + JSDOM** (`*.test.tsx`) | +| User interactions (button clicks, form validation, text typing) | **Vitest + JSDOM** (`*.test.tsx`) using `@testing-library/user-event` | +| Conditional DOM rendering or prop wiring | **Vitest + JSDOM** (`*.test.tsx`) | +| Visual layout, flexbox/grid alignment, or padding/margin verification | **Playwright CT** (`*.visual.tsx`) | +| VS Code dark/light theme CSS tokens (`--vscode-*`) | **Playwright CT** (`*.visual.tsx`) | +| Web component shadow DOM style encapsulation & upgrades | **Playwright CT** (`*.visual.tsx`) | + +--- + +## Unit & State Tests (Vitest + JSDOM) + - Prefer local `webview-ui` tests for React/webview behavior. If a change is about component rendering, local state, hooks, form dirty-state, validation, or prop wiring inside the webview, add or update Vitest coverage under `webview-ui/src/**/__tests__` instead of reaching for `apps/vscode-e2e`. - Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension environment: extension-host to webview messaging, VS Code workspace APIs, task execution flows, or other end-to-end behavior that needs `@vscode/test-electron`. - When a regression can be proven with a component or webview integration test, keep it in `webview-ui`. Do not promote it to e2e just because the UI is hosted inside VS Code. - For `SettingsView`, preserve the cached-state pattern from the repo root guidance: inputs should operate on local `cachedState` until the user saves, and tests should distinguish automatic initialization from real user edits. + +### Coverage & Codecov Quality Gates + +Codecov tracks `webview-ui` coverage under the `webview-ui` flag. + +- **Ratcheting (`target: auto`)**: Overall webview coverage will never drop below the current baseline as new tests are added. +- **Patch Gate (`target: 70%`)**: New or modified lines in PRs touching `webview-ui/src/` must meet minimum test coverage, ensuring state changes and new UI logic stay tested over time. + +--- + +## Visual Tests (Playwright CT) + +### When a UI change needs a snapshot + +If your PR changes anything a user would notice at a glance — layout, spacing, theme tokens, brand elements, gradients, mask/blur effects, hover/empty/error states — **add a `*.visual.tsx` snapshot to the same PR**. Do not attach screenshots to the PR description as evidence; commit the baseline instead so future PRs get regression coverage automatically. + +Visual regression is screen-based, not line-based — the goal is a small set of durable "pixel receipts" for the surfaces users see first, not blanket coverage. Prefer covering: + +- Onboarding / first-run surfaces (welcome view, hero, unconfigured state) +- Empty and error states (no history, provider misconfig, degraded modes) +- Theme-critical layouts that rely heavily on `--vscode-*` tokens or CSS masks/gradients +- One representative snapshot per user-facing screen — not per component + +Skip a visual test when the change is behavior-only (state transitions, handler wiring, validation) — those belong in Vitest. Visual tests are for what JSDOM cannot verify. + +### Where the two coverage flags fit + +- `webview-ui` (Vitest + JSDOM) — broad line coverage over component logic, hooks, and state. This is your main coverage gate. +- `webview-ui-ct` (Playwright CT) — narrow pixel-regression signal over a small set of critical screens. Low absolute % is expected and fine; the flag is not a coverage-to-hit target, it's a "did the surface still render the same" check. + +### Authoring rules + +- Keep behavioral assertions in Vitest. A `*.visual.tsx` test should establish a deterministic state and make a focused screenshot assertion. +- Run visual comparisons with `pnpm test:visual:docker` from `webview-ui/`. +- Update intentional baselines with `pnpm test:visual:docker:update` and commit the resulting `__screenshots__` files with the UI change. +- Use the Docker commands when creating or reviewing baselines; host-rendered screenshots are not the source of truth. +- If Docker is unavailable, `pnpm test:visual` can help diagnose test code, but do not create or update committed baselines from the host rendering environment. +- Keep visual tests limited to components supported by the current Playwright harness. Add shared extension state, translation, React Query, or other provider support before snapshotting components that require it. +- The current baseline naming assumes a single Chromium project. Include `{projectName}` in `snapshotPathTemplate` before adding another browser project. +- Import `test` and `expect` from `webview-ui/playwright/coverage-fixture.ts` (not directly from `@playwright/experimental-ct-react`) so the auto-fixture collects V8 coverage for `monocart-reporter` — that's what produces `coverage-ct/lcov.info` for the Codecov upload. diff --git a/webview-ui/docker-compose.visual.yml b/webview-ui/docker-compose.visual.yml new file mode 100644 index 0000000000..0c5ee01832 --- /dev/null +++ b/webview-ui/docker-compose.visual.yml @@ -0,0 +1,12 @@ +services: + visual: + image: mcr.microsoft.com/playwright:v1.60.0-noble@sha256:9bd26ad900bb5e0f4dee75839e957a89ae89c2b7ab1e76050e559790e946b948 + ipc: host + user: "${UID:-1000}:${GID:-1000}" + working_dir: /work + environment: + COREPACK_HOME: /work/node_modules/.cache/corepack + HOME: /tmp/playwright + volumes: + - ..:/work + command: sh -lc "corepack pnpm --filter @roo-code/vscode-webview test:visual" diff --git a/webview-ui/package.json b/webview-ui/package.json index 83c1a84f90..83777bcbf1 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -8,6 +8,9 @@ "pretest": "turbo run bundle --cwd ..", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:visual": "playwright test -c playwright-ct.config.ts", + "test:visual:docker": "node playwright/run-docker.mjs", + "test:visual:docker:update": "node playwright/run-docker.mjs --update", "format": "prettier --write src", "dev": "vite", "build": "tsc -b && vite build", @@ -83,6 +86,8 @@ "zod": "^3.25.61" }, "devDependencies": { + "@playwright/experimental-ct-react": "1.60.0", + "@playwright/test": "1.60.0", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@testing-library/jest-dom": "6.9.1", @@ -91,10 +96,9 @@ "@types/diff": "5.2.3", "@types/jest": "29.5.14", "@types/katex": "0.16.7", - "@types/node": "20.19.43", + "@types/node": "22.20.1", "@types/react": "18.3.31", "@types/react-dom": "18.3.7", - "@types/shell-quote": "1.7.5", "@types/stacktrace-js": "2.0.3", "@types/vscode-webview": "1.57.5", "@vitejs/plugin-react": "5.2.0", @@ -102,6 +106,7 @@ "@vitest/ui": "4.1.9", "babel-plugin-react-compiler": "1.0.0", "jsdom": "26.1.0", + "monocart-reporter": "^2.9.20", "vite": "8.1.0", "vitest": "4.1.9" } diff --git a/webview-ui/playwright-ct.config.ts b/webview-ui/playwright-ct.config.ts new file mode 100644 index 0000000000..3eb0abac7b --- /dev/null +++ b/webview-ui/playwright-ct.config.ts @@ -0,0 +1,100 @@ +import path from "path" +import { fileURLToPath } from "url" + +import { defineConfig } from "@playwright/experimental-ct-react" +import type { ReporterDescription } from "@playwright/test" +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" + +const dirname = path.dirname(fileURLToPath(import.meta.url)) + +const monocartReporter: ReporterDescription = [ + "monocart-reporter", + { + name: "Webview Playwright CT", + outputFile: path.resolve(dirname, "coverage-ct/index.html"), + coverage: { + outputDir: path.resolve(dirname, "coverage-ct"), + reports: ["lcovonly", "v8"], + // Entry URLs from Vite CT dev server or built bundle. + entryFilter: (entry: { url: string }) => entry.url.includes("/src/") || entry.url.includes("/assets/"), + sourceFilter: (sourcePath: string) => + sourcePath.includes("src/") && + !sourcePath.includes(".visual.") && + !sourcePath.includes(".spec.") && + !sourcePath.includes(".test."), + }, + }, +] + +export default defineConfig({ + testDir: "./src", + testMatch: "**/*.visual.tsx", + outputDir: path.resolve(dirname, "test-results"), + snapshotPathTemplate: "{testDir}/{testFileDir}/__screenshots__/{arg}{ext}", + fullyParallel: true, + reporter: process.env.CI + ? [ + ["html", { open: "never", outputFolder: path.resolve(dirname, "playwright-report") }], + ["github"], + ["list"], + monocartReporter, + ] + : [ + ["html", { open: "never", outputFolder: path.resolve(dirname, "playwright-report") }], + ["list"], + monocartReporter, + ], + use: { + ctTemplateDir: "./playwright", + ctViteConfig: { + plugins: [ + react({ + babel: { + plugins: [["babel-plugin-react-compiler", { target: "18" }]], + }, + }), + tailwindcss(), + ], + 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/webview-ui-toolkit/react": path.resolve( + dirname, + "./src/__mocks__/@vscode/webview-ui-toolkit/react.tsx", + ), + vscode: path.resolve(dirname, "../src/__mocks__/vscode.js"), + }, + }, + define: { + "process.platform": JSON.stringify(process.platform), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "test"), + "process.env.PKG_NAME": JSON.stringify("zoo-code"), + "process.env.PKG_VERSION": JSON.stringify("0.0.0-test"), + "process.env.PKG_OUTPUT_CHANNEL": JSON.stringify("Zoo-Code"), + "process.env.PKG_RELEASE_CHANNEL": JSON.stringify("stable"), + }, + optimizeDeps: { + exclude: ["@vscode/codicons"], + }, + publicDir: path.resolve(dirname, "../src/assets/images"), + }, + viewport: { width: 520, height: 360 }, + deviceScaleFactor: 1, + colorScheme: "dark", + }, + expect: { + toHaveScreenshot: { + animations: "disabled", + }, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}) 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/playwright/coverage-fixture.ts b/webview-ui/playwright/coverage-fixture.ts new file mode 100644 index 0000000000..d316f32053 --- /dev/null +++ b/webview-ui/playwright/coverage-fixture.ts @@ -0,0 +1,23 @@ +import { test as base, expect } from "@playwright/experimental-ct-react" +import { addCoverageReport } from "monocart-reporter" + +// Auto-fixture that collects V8 JS + CSS coverage per test and hands it to the +// monocart-reporter attached in `playwright-ct.config.ts`, which writes the +// LCOV consumed by Codecov. Every `.visual.tsx` file imports `test`/`expect` +// from here so coverage is collected without per-test boilerplate. +export const test = base.extend<{ collectCoverage: void }>({ + collectCoverage: [ + async ({ page }, use, testInfo) => { + await Promise.all([ + page.coverage.startJSCoverage({ resetOnNavigation: false }), + page.coverage.startCSSCoverage({ resetOnNavigation: false }), + ]) + await use() + const [js, css] = await Promise.all([page.coverage.stopJSCoverage(), page.coverage.stopCSSCoverage()]) + await addCoverageReport([...js, ...css], testInfo) + }, + { auto: true }, + ], +}) + +export { expect } diff --git a/webview-ui/playwright/index.html b/webview-ui/playwright/index.html new file mode 100644 index 0000000000..144ced8a48 --- /dev/null +++ b/webview-ui/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + webview-ui visual tests + + +
+ + + diff --git a/webview-ui/playwright/index.tsx b/webview-ui/playwright/index.tsx new file mode 100644 index 0000000000..67af70105d --- /dev/null +++ b/webview-ui/playwright/index.tsx @@ -0,0 +1,9 @@ +import "./vscode-theme-dark.css" +import "@vscode/codicons/dist/codicon.css" +import "../src/index.css" + +// Components read `window.IMAGES_BASE_URI` at mount time to resolve extension +// image assets. Under Playwright CT the extension host isn't present, so seed +// it to serve from the CT bundle root (mapped to src/assets/images via +// `publicDir` in playwright-ct.config.ts). +;(window as unknown as { IMAGES_BASE_URI: string }).IMAGES_BASE_URI = "" diff --git a/webview-ui/playwright/run-docker.mjs b/webview-ui/playwright/run-docker.mjs new file mode 100644 index 0000000000..cecd80bb31 --- /dev/null +++ b/webview-ui/playwright/run-docker.mjs @@ -0,0 +1,53 @@ +import { spawn, spawnSync } from "node:child_process" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const composeFile = path.resolve(dirname, "../docker-compose.visual.yml") + +const updateSnapshots = process.argv.includes("--update") +const composeArgs = ["-f", composeFile, "run", "--rm", "visual"] + +if (updateSnapshots) { + // Inlined so host-rendered baselines aren't reachable via a `pnpm` script. + composeArgs.push( + "sh", + "-lc", + "corepack pnpm --filter @roo-code/vscode-webview exec playwright test -c playwright-ct.config.ts --update-snapshots", + ) +} + +// Compose falls back to UID 1000 if not passed; that mis-owns generated files. +const spawnEnv = { + ...process.env, + UID: String(process.getuid?.() ?? 1000), + GID: String(process.getgid?.() ?? 1000), +} + +const hasComposePlugin = spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0 +const command = hasComposePlugin ? "docker" : "docker-compose" +const args = hasComposePlugin ? ["compose", ...composeArgs] : composeArgs + +const child = spawn(command, args, { stdio: "inherit", env: spawnEnv }) + +const forwardSignal = (signal) => { + if (child.pid && !child.killed) { + child.kill(signal) + } +} + +process.on("SIGINT", () => forwardSignal("SIGINT")) +process.on("SIGTERM", () => forwardSignal("SIGTERM")) + +child.on("error", (err) => { + console.error(`Unable to run ${command}: ${err.message}`) + process.exit(1) +}) + +child.on("close", (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + } else { + process.exit(code ?? 1) + } +}) diff --git a/webview-ui/playwright/vscode-theme-dark.css b/webview-ui/playwright/vscode-theme-dark.css new file mode 100644 index 0000000000..30d49f968b --- /dev/null +++ b/webview-ui/playwright/vscode-theme-dark.css @@ -0,0 +1,89 @@ +:root { + color-scheme: dark; + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --vscode-font-size: 13px; + --vscode-font-weight: normal; + --vscode-editor-font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --vscode-editor-font-size: 12px; + --vscode-editor-line-height: 18px; + --vscode-foreground: #cccccc; + --vscode-disabledForeground: #808080; + --vscode-descriptionForeground: #9d9d9d; + --vscode-errorForeground: #f48771; + --vscode-focusBorder: #007fd4; + --vscode-editor-foreground: #d4d4d4; + --vscode-editor-background: #1e1e1e; + --vscode-editorGroup-border: #2b2b2b; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWarning-background: #352a05; + --vscode-button-foreground: #ffffff; + --vscode-button-background: #0e639c; + --vscode-button-hoverBackground: #1177bb; + --vscode-button-secondaryForeground: #cccccc; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-dropdown-foreground: #f0f0f0; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-border: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-border: #3c3c3c; + --vscode-badge-foreground: #ffffff; + --vscode-badge-background: #4d4d4d; + --vscode-notifications-foreground: #cccccc; + --vscode-notifications-background: #252526; + --vscode-notifications-border: #303031; + --vscode-list-hoverForeground: #cccccc; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-focusBackground: #04395e; + --vscode-list-activeSelectionBackground: #04395e; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-toolbar-hoverBackground: #2a2d2e; + --vscode-toolbar-hoverOutline: #00000000; + --vscode-panel-border: #2b2b2b; + --vscode-progressBar-background: #0e70c0; + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBar-background: #252526; + --vscode-sideBar-border: #2b2b2b; + --vscode-sideBarSectionHeader-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #00000000; + --vscode-sideBarSectionHeader-border: #2b2b2b; + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-inactiveForeground: #999999; + --vscode-charts-blue: #3794ff; + --vscode-charts-green: #89d185; + --vscode-charts-orange: #d18616; + --vscode-charts-red: #f14c4c; + --vscode-charts-yellow: #cca700; + --vscode-inputValidation-infoForeground: #d4d4d4; + --vscode-inputValidation-infoBackground: #063b49; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningForeground: #d4d4d4; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-widget-border: #303031; + --vscode-widget-shadow: #0000005c; + --vscode-textLink-foreground: #3794ff; + --vscode-textCodeBlock-background: #2d2d2d; + --vscode-menu-foreground: #cccccc; + --vscode-menu-background: #252526; + --vscode-editorHoverWidget-foreground: #cccccc; + --vscode-editorHoverWidget-background: #252526; + --vscode-editorHoverWidget-border: #454545; + --vscode-banner-background: #04395e; + --vscode-banner-foreground: #ffffff; + --vscode-scrollbarSlider-background: #79797966; + --vscode-scrollbarSlider-hoverBackground: #646464b3; + --vscode-scrollbarSlider-activeBackground: #bfbfbf66; +} + +body { + margin: 0; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +#root { + padding: 16px; +} 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 { + if (awaitingChildId) { + vscode.postMessage({ type: "showTaskWithId", text: awaitingChildId }) + } + } + return (
{isSubtask && ( @@ -113,6 +129,19 @@ const TaskHeader = ({
)} + {awaitingChildId && ( +
e.stopPropagation()}> + +
+ )}
({ vi.mock("@roo/package", () => ({ Package: { - version: "3.55.0", + version: "3.72.0", }, })) @@ -41,11 +41,11 @@ vi.mock("@src/i18n/TranslationContext", () => ({ const translations: Record = { "chat:announcement.release.heading": "What's New:", "chat:announcement.release.highlight1": - "Xiaomi MiMo provider: Added Xiaomi MiMo as a first-class API provider so you can configure MiMo models directly in Zoo Code.", + "Moonshot and Kimi Code providers — connect to Moonshot models with live model discovery, or sign in to Kimi Code through its OAuth device flow.", "chat:announcement.release.highlight2": - "Upstream Zoo Code handoff: Pulled in the latest upstream sunset merge and related platform updates to keep Zoo Code aligned with the community handoff work.", + "Latest model support — use Claude Opus 5 across providers, plus Kimi K3, Gemini 3.6 Flash, and MiniMax-M3.", "chat:announcement.release.highlight3": - "Stability fixes across chat and providers: Fixed MCP sign-in copy, Gemini full-tool requests, OpenAI temperature handling, and Markdown single-tilde rendering.", + "More reliable workflows — abandon interrupted subtasks cleanly, and index Dart and plain-text files in your codebase.", "chat:announcement.handoff.heading": "The Roo Code plugin is not going away.", } @@ -62,20 +62,20 @@ describe("Announcement", () => { it("renders the announcement title and highlights", () => { render() - expect(screen.getByText("Zoo Code 3.55.0 Released")).toBeInTheDocument() + expect(screen.getByText("Zoo Code 3.72.0 Released")).toBeInTheDocument() expect( screen.getByText( - "Xiaomi MiMo provider: Added Xiaomi MiMo as a first-class API provider so you can configure MiMo models directly in Zoo Code.", + "Moonshot and Kimi Code providers — connect to Moonshot models with live model discovery, or sign in to Kimi Code through its OAuth device flow.", ), ).toBeInTheDocument() expect( screen.getByText( - "Upstream Zoo Code handoff: Pulled in the latest upstream sunset merge and related platform updates to keep Zoo Code aligned with the community handoff work.", + "Latest model support — use Claude Opus 5 across providers, plus Kimi K3, Gemini 3.6 Flash, and MiniMax-M3.", ), ).toBeInTheDocument() expect( screen.getByText( - "Stability fixes across chat and providers: Fixed MCP sign-in copy, Gemini full-tool requests, OpenAI temperature handling, and Markdown single-tilde rendering.", + "More reliable workflows — abandon interrupted subtasks cleanly, and index Dart and plain-text files in your codebase.", ), ).toBeInTheDocument() }) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 0295720d18..66169e0bac 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1134,6 +1134,69 @@ describe("ChatView - Message Queueing Tests", () => { }), ) }) + + 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/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index ca7d8c8ce6..9d23ca6886 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -40,6 +40,7 @@ const mockExtensionState: { apiConfiguration: ProviderSettings currentTaskItem: { id: string } | null clineMessages: any[] + taskHistory: any[] } = { apiConfiguration: { apiProvider: "anthropic", @@ -48,6 +49,7 @@ const mockExtensionState: { } as ProviderSettings, currentTaskItem: { id: "test-task-id" }, clineMessages: [], + taskHistory: [], } // Mock the ExtensionStateContext @@ -223,6 +225,58 @@ describe("TaskHeader", () => { }) }) + describe("Delegated parent waiting-on-subtask banner", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + afterEach(() => { + mockExtensionState.currentTaskItem = { id: "test-task-id" } + mockExtensionState.taskHistory = [] + }) + + it("does not show the banner when currentTaskItem is not delegated", () => { + mockExtensionState.currentTaskItem = { id: "test-task-id", status: "active" } as any + renderTaskHeader() + expect(screen.queryByText("chat:task.waitingOnSubtask")).not.toBeInTheDocument() + }) + + it("shows the banner when currentTaskItem is delegated with an awaitingChildId", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + expect(screen.getByText("chat:task.waitingOnSubtask")).toBeInTheDocument() + }) + + it("navigates to the awaited child when the banner is clicked", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + + fireEvent.click(screen.getByText("chat:task.waitingOnSubtask")) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "showTaskWithId", text: "child-1" }) + }) + + it("does not show an Abandon button (removed: implicit sever on re-delegation)", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + + expect(screen.getByText("chat:task.waitingOnSubtask")).toBeInTheDocument() + expect(screen.queryByText("history:abandonSubtask")).not.toBeInTheDocument() + }) + }) + describe("Context window percentage calculation", () => { // The percentage should be calculated as: // contextTokens / (contextWindow - reservedForOutput) * 100 diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index 0089e1f81d..c0aa88489a 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -6,6 +6,7 @@ import type { SubtaskTreeNode } from "./types" import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" +import { TaskStatusBadge } from "./TaskStatusBadge" interface SubtaskRowProps { /** The subtask tree node to display */ @@ -52,6 +53,9 @@ const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) {item.task} + {(item.status === "delegated" || item.status === "interrupted") && ( + + )}
diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index d0dc367e64..72c6b64420 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -7,6 +7,7 @@ import { DeleteButton } from "./DeleteButton" import { StandardTooltip } from "../ui/standard-tooltip" import { useAppTranslation } from "@/i18n/TranslationContext" import { Split } from "lucide-react" +import { TaskStatusBadge } from "./TaskStatusBadge" export interface TaskItemFooterProps { item: HistoryItem @@ -36,6 +37,13 @@ const TaskItemFooter: React.FC = ({ · )} + {/* Delegation status (delegated parent waiting on a child, or interrupted child) */} + {(item.status === "delegated" || item.status === "interrupted") && ( + <> + + · + + )} {/* Datetime with time-ago format */} {formatTimeAgo(item.ts)} diff --git a/webview-ui/src/components/history/TaskStatusBadge.tsx b/webview-ui/src/components/history/TaskStatusBadge.tsx new file mode 100644 index 0000000000..5278f1422e --- /dev/null +++ b/webview-ui/src/components/history/TaskStatusBadge.tsx @@ -0,0 +1,36 @@ +import { useAppTranslation } from "@/i18n/TranslationContext" +import { cn } from "@/lib/utils" +import { StandardTooltip } from "../ui" + +interface TaskStatusBadgeProps { + status: "delegated" | "interrupted" + className?: string +} + +/** + * Small inline badge for a task's delegation status: a parent "delegated" and + * waiting on a subtask, or a child that was "interrupted" mid-execution and + * can be resumed rather than silently detached. See #559. + */ +export const TaskStatusBadge = ({ status, className }: TaskStatusBadgeProps) => { + const { t } = useAppTranslation() + + const isInterrupted = status === "interrupted" + const icon = isInterrupted ? "codicon-warning" : "codicon-sync" + const label = isInterrupted ? t("history:interruptedTag") : t("history:delegatedTag") + + return ( + + + + {label} + + + ) +} diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index aa334d94c2..fc8d7edcca 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -94,4 +94,24 @@ describe("TaskItemFooter", () => { expect(screen.queryByText("history:subtaskTag")).not.toBeInTheDocument() }) + + it("shows a delegated status badge when status is delegated", () => { + render( + , + ) + + expect(screen.getByTestId("task-status-badge-delegated")).toBeInTheDocument() + }) + + it("shows an interrupted status badge when status is interrupted", () => { + render() + + expect(screen.getByTestId("task-status-badge-interrupted")).toBeInTheDocument() + }) + + it("does not show a status badge for a completed task", () => { + render() + + expect(screen.queryByTestId(/task-status-badge-/)).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/mcp/McpEnabledToggle.tsx b/webview-ui/src/components/mcp/McpEnabledToggle.tsx index 1fd30034b5..3e6e5cf9ff 100644 --- a/webview-ui/src/components/mcp/McpEnabledToggle.tsx +++ b/webview-ui/src/components/mcp/McpEnabledToggle.tsx @@ -5,10 +5,22 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { useAppTranslation } from "@src/i18n/TranslationContext" import { vscode } from "@src/utils/vscode" -const McpEnabledToggle = () => { - const { mcpEnabled, setMcpEnabled } = useExtensionState() +interface McpEnabledToggleProps { + mcpEnabled?: boolean + setMcpEnabled?: (value: boolean) => void +} + +const McpEnabledToggle = ({ + mcpEnabled: propsMcpEnabled, + setMcpEnabled: propsSetMcpEnabled, +}: McpEnabledToggleProps = {}) => { + const { mcpEnabled: contextMcpEnabled, setMcpEnabled: contextSetMcpEnabled } = useExtensionState() const { t } = useAppTranslation() + // When rendered inside SettingsView the value is buffered in `cachedState` and + // only persisted on Save. Fall back to live extension state when used uncontrolled. + const mcpEnabled = propsMcpEnabled ?? contextMcpEnabled + const handleChange = (e: Event | FormEvent) => { const target = ("target" in e ? e.target : null) as HTMLInputElement | null @@ -16,8 +28,12 @@ const McpEnabledToggle = () => { return } - setMcpEnabled(target.checked) - vscode.postMessage({ type: "updateSettings", updatedSettings: { mcpEnabled: target.checked } }) + if (propsSetMcpEnabled) { + propsSetMcpEnabled(target.checked) + } else { + contextSetMcpEnabled(target.checked) + vscode.postMessage({ type: "updateSettings", updatedSettings: { mcpEnabled: target.checked } }) + } } return ( diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 75a9a1a380..72724d72e8 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -28,8 +28,17 @@ import McpResourceRow from "./McpResourceRow" import McpEnabledToggle from "./McpEnabledToggle" import { McpErrorRow } from "./McpErrorRow" -const McpView = () => { - const { mcpServers: servers, alwaysAllowMcp, mcpEnabled } = useExtensionState() +interface McpViewProps { + mcpEnabled?: boolean + setMcpEnabled?: (value: boolean) => void +} + +const McpView = ({ mcpEnabled: propsMcpEnabled, setMcpEnabled }: McpViewProps = {}) => { + const { mcpServers: servers, alwaysAllowMcp, mcpEnabled: contextMcpEnabled } = useExtensionState() + + // When rendered inside SettingsView the value is buffered in `cachedState` and + // only persisted on Save. Fall back to live extension state when used uncontrolled. + const mcpEnabled = propsMcpEnabled ?? contextMcpEnabled const { t } = useAppTranslation() const { isOverThreshold, title, message } = useTooManyTools() @@ -55,7 +64,7 @@ const McpView = () => { - + {mcpEnabled && ( <> diff --git a/webview-ui/src/components/mcp/__tests__/McpEnabledToggle.spec.tsx b/webview-ui/src/components/mcp/__tests__/McpEnabledToggle.spec.tsx new file mode 100644 index 0000000000..f59efce971 --- /dev/null +++ b/webview-ui/src/components/mcp/__tests__/McpEnabledToggle.spec.tsx @@ -0,0 +1,58 @@ +// npx vitest src/components/mcp/__tests__/McpEnabledToggle.spec.tsx + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import McpEnabledToggle from "../McpEnabledToggle" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +const contextSetMcpEnabled = vi.fn() +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpEnabled: true, + setMcpEnabled: contextSetMcpEnabled, + }), +})) + +const getCheckbox = () => screen.getByRole("checkbox") + +describe("McpEnabledToggle - Save/Discard contract", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Case 6: controlled (inside SettingsView) must buffer, not persist before Save. + it("buffers via the setter prop without persisting before Save when controlled", () => { + const setMcpEnabled = vi.fn() + render() + + fireEvent.click(getCheckbox()) + + expect(setMcpEnabled).toHaveBeenCalledWith(false) + expect(vscode.postMessage).not.toHaveBeenCalled() + // Must not touch live extension state either. + expect(contextSetMcpEnabled).not.toHaveBeenCalled() + }) + + // Regression guard: uncontrolled usage keeps the original immediate behavior. + it("persists immediately via live state when used uncontrolled", () => { + render() + + fireEvent.click(getCheckbox()) + + expect(contextSetMcpEnabled).toHaveBeenCalledWith(false) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateSettings", + updatedSettings: { mcpEnabled: false }, + }) + }) +}) diff --git a/webview-ui/src/components/mcp/__tests__/McpView.spec.tsx b/webview-ui/src/components/mcp/__tests__/McpView.spec.tsx new file mode 100644 index 0000000000..957797d1e9 --- /dev/null +++ b/webview-ui/src/components/mcp/__tests__/McpView.spec.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@/utils/test-utils" + +import McpView from "../McpView" + +const setMcpEnabled = vi.fn() + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + mcpEnabled: false, + }), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/hooks/useTooManyTools", () => ({ + useTooManyTools: () => ({ isOverThreshold: false, title: "", message: "" }), +})) + +vi.mock("@src/components/settings/Section", () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/settings/SectionHeader", () => ({ + SectionHeader: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + ToggleSwitch: () => null, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("../McpEnabledToggle", () => ({ + default: ({ + mcpEnabled, + setMcpEnabled: setEnabled, + }: { + mcpEnabled: boolean + setMcpEnabled?: (value: boolean) => void + }) => ( + + ), +})) + +describe("McpView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("uses extension state when no buffered value is provided", () => { + render() + + expect(screen.getByTestId("mcp-enabled-toggle")).toHaveTextContent("false") + }) + + it("uses and updates the buffered value when controlled", () => { + render() + + fireEvent.click(screen.getByTestId("mcp-enabled-toggle")) + + expect(setMcpEnabled).toHaveBeenCalledWith(false) + }) +}) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2585a674ec..c5e69978ff 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -56,6 +56,7 @@ import { LiteLLM, Mistral, Moonshot, + KimiCode, Ollama, OpenAI, OpenAICompatible, @@ -116,7 +117,8 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, openAiCodexIsAuthenticated } = useExtensionState() + const { organizationAllowList, openAiCodexIsAuthenticated, kimiCodeIsAuthenticated, kimiCodeOAuthState } = + useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -219,7 +221,13 @@ const ApiOptions = ({ }, }) } else if (selectedProvider === "ollama") { - vscode.postMessage({ type: "requestOllamaModels" }) + vscode.postMessage({ + type: "requestOllamaModels", + values: { + baseUrl: apiConfiguration?.ollamaBaseUrl, + apiKey: apiConfiguration?.ollamaApiKey, + }, + }) } else if (selectedProvider === "lmstudio") { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) } else if (selectedProvider === "vscode-lm") { @@ -243,6 +251,7 @@ const ApiOptions = ({ apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, apiConfiguration?.ollamaBaseUrl, + apiConfiguration?.ollamaApiKey, apiConfiguration?.lmStudioBaseUrl, apiConfiguration?.litellmBaseUrl, apiConfiguration?.litellmApiKey, @@ -575,6 +584,15 @@ const ApiOptions = ({ /> )} + {selectedProvider === "kimi-code" && ( + + )} + {selectedProvider === "minimax" && ( { const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index) setCachedStateField("allowedCommands", newCommands) - - vscode.postMessage({ - type: "updateSettings", - updatedSettings: { allowedCommands: newCommands }, - }) }}>
{cmd}
@@ -376,11 +369,6 @@ export const AutoApproveSettings = ({ onClick={() => { const newCommands = (deniedCommands ?? []).filter((_, i) => i !== index) setCachedStateField("deniedCommands", newCommands) - - vscode.postMessage({ - type: "updateSettings", - updatedSettings: { deniedCommands: newCommands }, - }) }}>
{cmd}
diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 3127dde363..c3af315d99 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -24,7 +24,6 @@ import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" -import { vscode } from "@/utils/vscode" type ContextManagementSettingsProps = HTMLAttributes & { autoCondenseContext: boolean @@ -139,7 +138,6 @@ export const ContextManagementSettings = ({ } setCachedStateField("profileThresholds", newThresholds) - vscode.postMessage({ type: "updateSettings", updatedSettings: { profileThresholds: newThresholds } }) } } return ( 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/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 54babbcfcb..d6e0ba0f6b 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -209,10 +209,16 @@ const PromptsSettings = ({ setIncludeTaskHistoryInEnhance(target.checked) - vscode.postMessage({ - type: "updateSettings", - updatedSettings: { includeTaskHistoryInEnhance: target.checked }, - }) + // Controlled: setter buffers into cachedState (Save persists). + // Uncontrolled: context setter is local-only, so persist here. + if (!propsSetIncludeTaskHistoryInEnhance) { + vscode.postMessage({ + type: "updateSettings", + updatedSettings: { + includeTaskHistoryInEnhance: target.checked, + }, + }) + } }}> {t("prompts:supportPrompts.enhance.includeTaskHistory")} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 87052a21a2..c45ca38eea 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -130,7 +130,7 @@ const SettingsView = forwardRef(({ onDone, t const { t } = useAppTranslation() const extensionState = useExtensionState() - const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState + const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt, mode } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) @@ -147,6 +147,7 @@ const SettingsView = forwardRef(({ onDone, t const contentRef = useRef(null) const prevApiConfigName = useRef(currentApiConfigName) + const prevMode = useRef(mode) const handledSettingsImportedAt = useRef(undefined) const confirmDialogHandler = useRef<() => void>() @@ -220,16 +221,17 @@ const SettingsView = forwardRef(({ onDone, t const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) useEffect(() => { - // Update only when currentApiConfigName is changed. - // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration. - if (prevApiConfigName.current === currentApiConfigName) { + // Update when currentApiConfigName or mode changes. + // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration or mode switch. + if (prevApiConfigName.current === currentApiConfigName && prevMode.current === mode) { return } setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName + prevMode.current = mode setChangeDetected(false) - }, [currentApiConfigName, extensionState]) + }, [currentApiConfigName, mode, extensionState]) // Bust the cache when settings are imported. useEffect(() => { @@ -899,7 +901,12 @@ const SettingsView = forwardRef(({ onDone, t {renderTab === "modes" && } {/* MCP Section */} - {renderTab === "mcp" && } + {renderTab === "mcp" && ( + setCachedStateField("mcpEnabled", value)} + /> + )} {/* Worktrees Section */} {renderTab === "worktrees" && } diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx new file mode 100644 index 0000000000..8b736280ee --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -0,0 +1,100 @@ +// npx vitest src/components/settings/__tests__/AutoApproveSettings.spec.tsx + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { AutoApproveSettings } from "../AutoApproveSettings" +import { vscode } from "@/utils/vscode" + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +// AutoApproveSettings reads a couple of live-state values that are genuinely +// immediate actions (autoApprovalEnabled). Those are out of scope for the +// Save/Discard buffering contract, so we just provide inert stand-ins. +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + autoApprovalEnabled: false, + setAutoApprovalEnabled: vi.fn(), + }), +})) + +vi.mock("@/hooks/useAutoApprovalToggles", () => ({ + useAutoApprovalToggles: () => ({}), +})) + +vi.mock("@/hooks/useAutoApprovalState", () => ({ + useAutoApprovalState: () => ({ effectiveAutoApprovalEnabled: false, hasEnabledOptions: false }), +})) + +const renderSettings = (overrides = {}) => { + const setCachedStateField = vi.fn() + const props = { + alwaysAllowExecute: true, // reveal the command list section + allowedCommands: [] as string[], + deniedCommands: [] as string[], + setCachedStateField, + ...overrides, + } + render() + return { setCachedStateField } +} + +// A change is "Save-managed" if it must NOT reach the extension host before Save. +const expectNoImmediateUpdateSettings = () => { + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) +} + +describe("AutoApproveSettings - Save/Discard contract", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Case 1: allowedCommands add + it("buffers an added allowed command without persisting before Save", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.change(screen.getByTestId("command-input"), { target: { value: "npm test" } }) + fireEvent.click(screen.getByTestId("add-command-button")) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", ["npm test"]) + expectNoImmediateUpdateSettings() + }) + + // Case 2: allowedCommands remove + it("buffers a removed allowed command without persisting before Save", () => { + const { setCachedStateField } = renderSettings({ allowedCommands: ["npm test"] }) + + fireEvent.click(screen.getByTestId("remove-command-0")) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", []) + expectNoImmediateUpdateSettings() + }) + + // Case 3a: deniedCommands add + it("buffers an added denied command without persisting before Save", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.change(screen.getByTestId("denied-command-input"), { target: { value: "rm -rf" } }) + fireEvent.click(screen.getByTestId("add-denied-command-button")) + + expect(setCachedStateField).toHaveBeenCalledWith("deniedCommands", ["rm -rf"]) + expectNoImmediateUpdateSettings() + }) + + // Case 3b: deniedCommands remove + it("buffers a removed denied command without persisting before Save", () => { + const { setCachedStateField } = renderSettings({ deniedCommands: ["rm -rf"] }) + + fireEvent.click(screen.getByTestId("remove-denied-command-0")) + + expect(setCachedStateField).toHaveBeenCalledWith("deniedCommands", []) + expectNoImmediateUpdateSettings() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index 57b3066b4d..10d5d0fe4f 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" import { ContextManagementSettings } from "../ContextManagementSettings" +import { vscode } from "@/utils/vscode" // Mock the translation hook vi.mock("@/hooks/useAppTranslation", () => ({ @@ -47,8 +48,16 @@ vi.mock("@/components/ui", () => ({ {children} ), - Select: ({ children, ...props }: any) => ( -
+ Select: ({ children, value, onValueChange, ...props }: any) => ( +
+ {/* Hidden trigger lets tests drive profile selection deterministically: + set data-next-value on the button, then click it. */} +
), @@ -391,6 +400,26 @@ describe("ContextManagementSettings", () => { render() expect(screen.getByText("75%")).toBeInTheDocument() }) + + // Case 4: profileThresholds must buffer through cachedState, not persist before Save. + it("buffers profile threshold changes without persisting before Save", () => { + const mockSetCachedStateField = vitest.fn() + const props = { ...autoCondenseProps, setCachedStateField: mockSetCachedStateField } + render() + + // Select a non-default profile so the slider edits profileThresholds. + const profileTrigger = screen.getByTestId("threshold-profile-change") + profileTrigger.setAttribute("data-next-value", "config-1") + fireEvent.click(profileTrigger) + + // Move the threshold slider for that profile. + const slider = screen.getByTestId("condense-threshold-slider") + slider.focus() + fireEvent.keyDown(slider, { key: "ArrowRight" }) + + expect(mockSetCachedStateField).toHaveBeenCalledWith("profileThresholds", { "config-1": 76 }) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) + }) }) it("handles boundary values for sliders", () => { 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__/PromptsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/PromptsSettings.spec.tsx new file mode 100644 index 0000000000..d10e100f45 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/PromptsSettings.spec.tsx @@ -0,0 +1,91 @@ +// npx vitest src/components/settings/__tests__/PromptsSettings.spec.tsx + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import PromptsSettings from "../PromptsSettings" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +// Exposed so tests can assert the context (uncontrolled) setter still fires. +const { contextSetIncludeTaskHistoryInEnhance } = vi.hoisted(() => ({ + contextSetIncludeTaskHistoryInEnhance: vi.fn(), +})) + +// PromptsSettings falls back to context when props are absent. +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + listApiConfigMeta: [], + enhancementApiConfigId: "", + setEnhancementApiConfigId: vi.fn(), + includeTaskHistoryInEnhance: true, + setIncludeTaskHistoryInEnhance: contextSetIncludeTaskHistoryInEnhance, + }), +})) + +const renderSettings = (overrides = {}) => { + const setIncludeTaskHistoryInEnhance = vi.fn() + const props = { + customSupportPrompts: {}, + setCustomSupportPrompts: vi.fn(), + includeTaskHistoryInEnhance: true, + setIncludeTaskHistoryInEnhance, + ...overrides, + } + render() + return { setIncludeTaskHistoryInEnhance } +} + +describe("PromptsSettings - Save/Discard contract", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Case 5: includeTaskHistoryInEnhance must buffer, not persist before Save. + it("buffers includeTaskHistoryInEnhance toggle without persisting before Save", () => { + const { setIncludeTaskHistoryInEnhance } = renderSettings({ includeTaskHistoryInEnhance: true }) + + // The ENHANCE support option is active by default, so the checkbox is present. + const checkbox = screen + .getByText("prompts:supportPrompts.enhance.includeTaskHistory") + .closest("label")! + .querySelector('input[type="checkbox"]')! + fireEvent.click(checkbox) + + expect(setIncludeTaskHistoryInEnhance).toHaveBeenCalledWith(false) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) + }) + + // Uncontrolled usage (props omitted) has no cachedState/Save wiring, so the + // toggle must persist immediately via updateSettings — otherwise it is lost. + it("persists includeTaskHistoryInEnhance immediately when used uncontrolled", () => { + render( + , + ) + + const checkbox = screen + .getByText("prompts:supportPrompts.enhance.includeTaskHistory") + .closest("label")! + .querySelector('input[type="checkbox"]')! + fireEvent.click(checkbox) + + // Still updates local (context) state, and also persists immediately. + expect(contextSetIncludeTaskHistoryInEnhance).toHaveBeenCalledWith(false) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateSettings", + updatedSettings: { includeTaskHistoryInEnhance: false }, + }) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 2ca7107335..68a6efb106 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -1,5 +1,5 @@ import { act, render, screen, fireEvent, waitFor, configure } from "@testing-library/react" -import { vi, describe, it, expect, beforeEach } from "vitest" +import { vi, describe, it, expect, beforeEach, beforeAll } from "vitest" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" @@ -19,8 +19,6 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -// Import the actual component -import SettingsView from "../SettingsView" import { useExtensionState } from "@src/context/ExtensionStateContext" // Mock the extension state context @@ -28,10 +26,12 @@ vi.mock("@src/context/ExtensionStateContext", () => ({ useExtensionState: vi.fn(), })) +const mockTranslate = vi.hoisted(() => (key: string) => key) + // Mock the translation context vi.mock("@src/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => key, + t: mockTranslate, }), })) @@ -94,6 +94,18 @@ vi.mock("@src/components/ui", () => ({ TooltipProvider: ({ children }: any) => <>{children}, TooltipTrigger: ({ children }: any) => <>{children}, TooltipContent: ({ children }: any) =>
{children}
, + Command: ({ children }: any) =>
{children}
, + CommandInput: ({ value, onValueChange }: any) => ( + onValueChange(e.target.value)} /> + ), + CommandGroup: ({ children }: any) =>
{children}
, + CommandItem: ({ children, onSelect }: any) => ( +
+ {children} +
+ ), + CommandList: ({ children }: any) =>
{children}
, + CommandEmpty: ({ children }: any) =>
{children}
, Select: ({ children, value, onValueChange }: any) => (
@@ -181,13 +193,63 @@ vi.mock("@src/components/mcp/McpView", () => ({ default: () => null, })) -// Mock Tab components -vi.mock("../common/Tab", () => ({ +vi.mock("../../common/Tab", () => ({ Tab: ({ children }: any) =>
{children}
, - TabContent: React.forwardRef(({ children }, ref) =>
{children}
), + TabContent: React.forwardRef(({ children, ...props }, ref) => ( +
+ {children} +
+ )), TabHeader: ({ children }: any) =>
{children}
, - TabList: ({ children }: any) =>
{children}
, - TabTrigger: React.forwardRef(({ children }, ref) => ), + TabList: ({ children, value, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child + } + + const element = child as React.ReactElement + return React.cloneElement(element, { + isSelected: element.props.value === value, + onSelect: () => onValueChange(element.props.value), + }) + })} +
+ ), + TabTrigger: React.forwardRef(({ children, onSelect, ...props }, ref) => ( + + )), +})) +vi.mock("@src/components/common/Tab", () => ({ + Tab: ({ children }: any) =>
{children}
, + TabContent: React.forwardRef(({ children, ...props }, ref) => ( +
+ {children} +
+ )), + TabHeader: ({ children }: any) =>
{children}
, + TabList: ({ children, value, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child + } + + const element = child as React.ReactElement + return React.cloneElement(element, { + isSelected: element.props.value === value, + onSelect: () => onValueChange(element.props.value), + }) + })} +
+ ), + TabTrigger: React.forwardRef(({ children, onSelect, ...props }, ref) => ( + + )), })) // Mock all child components to isolate the test @@ -195,25 +257,30 @@ vi.mock("../ApiConfigManager", () => ({ default: () => null, })) +const mockApiOptions = ({ apiConfiguration, setApiConfigurationField }: any) => ( +
+ {apiConfiguration.apiProvider} + setApiConfigurationField("basetenApiKey", event.target.value)} + /> + {["openrouter", "baseten", "deepseek", "friendli"].map((provider) => ( + + ))} +
+) + vi.mock("../ApiOptions", () => ({ - default: ({ apiConfiguration, setApiConfigurationField }: any) => ( -
- {apiConfiguration.apiProvider} - setApiConfigurationField("basetenApiKey", event.target.value)} - /> - {["openrouter", "baseten", "deepseek", "friendli"].map((provider) => ( - - ))} -
- ), + default: mockApiOptions, +})) +vi.mock("@src/components/settings/ApiOptions", () => ({ + default: mockApiOptions, })) vi.mock("../AutoApproveSettings", () => ({ @@ -228,6 +295,43 @@ vi.mock("../Section", () => ({ Section: ({ children }: any) =>
{children}
, })) +vi.mock("../SearchableSetting", () => ({ + SearchableSetting: ({ children }: any) =>
{children}
, +})) +vi.mock("../useSettingsSearch", () => ({ + SearchIndexProvider: ({ children }: any) => <>{children}, + useSearchIndexRegistry: () => ({ + contextValue: { registerSetting: vi.fn() }, + index: [], + }), + useSettingsSearch: () => ({ + searchQuery: "", + setSearchQuery: vi.fn(), + results: [], + isOpen: false, + setIsOpen: vi.fn(), + clearSearch: vi.fn(), + }), +})) +vi.mock("@src/components/settings/SearchableSetting", () => ({ + SearchableSetting: ({ children }: any) =>
{children}
, +})) +vi.mock("@src/components/settings/useSettingsSearch", () => ({ + SearchIndexProvider: ({ children }: any) => <>{children}, + useSearchIndexRegistry: () => ({ + contextValue: { registerSetting: vi.fn() }, + index: [], + }), + useSettingsSearch: () => ({ + searchQuery: "", + setSearchQuery: vi.fn(), + results: [], + isOpen: false, + setIsOpen: vi.fn(), + clearSearch: vi.fn(), + }), +})) + // Mock all settings components vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: () => null, @@ -263,6 +367,11 @@ vi.mock("../UISettings", () => ({ vi.mock("../SettingsSearch", () => ({ SettingsSearch: () => null, })) +vi.mock("@src/components/settings/SettingsSearch", () => ({ + SettingsSearch: () => null, +})) + +let SettingsView: typeof import("../SettingsView").default describe("SettingsView - Change Detection Fix", () => { let queryClient: QueryClient @@ -332,9 +441,16 @@ describe("SettingsView - Change Detection Fix", () => { autoCloseZooOpenedFiles: true, autoCloseZooOpenedFilesAfterUserEdited: false, autoCloseZooOpenedNewFiles: false, + mode: "code", ...overrides, }) + beforeAll(async () => { + // Import after mocks are registered so the isolated tests use the + // lightweight child component mocks above instead of the full settings UI. + SettingsView = (await import("../SettingsView")).default + }) + beforeEach(() => { vi.clearAllMocks() queryClient = new QueryClient({ @@ -373,7 +489,7 @@ describe("SettingsView - Change Detection Fix", () => { // onDone should be called expect(onDone).toHaveBeenCalled() - }) + }, 10000) // These tests are passing for the basic case but failing due to vi.doMock limitations // The core fix has been verified - when no actual changes are made, no unsaved changes dialog appears @@ -394,7 +510,7 @@ describe("SettingsView - Change Detection Fix", () => { // - null -> value (initialization from null) expect(true).toBe(true) // Placeholder - the real test is the running system - }) + }, 10000) it("preserves a DeepSeek provider edit after saving Baseten when the same import timestamp replays", async () => { const onDone = vi.fn() @@ -473,7 +589,7 @@ describe("SettingsView - Change Detection Fix", () => { apiProvider: "deepseek", }), }) - }) + }, 10000) it("resets cached provider state when a new import timestamp arrives", async () => { const onDone = vi.fn() @@ -525,5 +641,152 @@ describe("SettingsView - Change Detection Fix", () => { await waitFor(() => { expect(screen.getByTestId("save-button")).toBeDisabled() }) + }, 10000) + + describe("mode synchronization", () => { + it("resets changeDetected and syncs cachedState when mode changes after dirty state", async () => { + const onDone = vi.fn() + let extensionState = createExtensionState({ + mode: "code", + apiConfiguration: { + apiProvider: "openai", + apiModelId: "gpt-4.1", + }, + }) + + ;(useExtensionState as any).mockImplementation(() => extensionState) + + const { rerender } = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") + }) + + // Make a dirty change by switching provider + fireEvent.click(screen.getByTestId("set-provider-baseten")) + expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten") + + // Verify save button is enabled (dirty state) + const saveButton = screen.getByTestId("save-button") as HTMLButtonElement + expect(saveButton.disabled).toBe(false) + + // Now change only the mode-dependent values while keeping extensionState's + // object identity stable. This makes the `mode` dependency load-bearing: + // without it, React would not re-run the sync effect. + await act(async () => { + extensionState.mode = "ask" + extensionState.apiConfiguration = { + apiProvider: "openrouter", + apiModelId: "claude-3.5-sonnet", + } + + rerender( + + + , + ) + }) + + // Let the mode sync effect run + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + // Verify cachedState reflects the new mode's settings + await waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent("openrouter") + }) + + // Verify changeDetected is reset (save button should be disabled) + const updatedSaveButton = screen.getByTestId("save-button") as HTMLButtonElement + expect(updatedSaveButton.disabled).toBe(true) + + // Make another dirty change while already in the new mode. + fireEvent.click(screen.getByTestId("set-provider-deepseek")) + expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek") + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false) + + // Re-render with a new extensionState identity but the same mode and config + // name. If prevMode.current is not updated during the first mode transition, + // the stale ref makes this same-mode render look like another mode change and + // incorrectly overwrites the dirty cached provider below. + await act(async () => { + extensionState = createExtensionState({ + mode: "ask", + apiConfiguration: { + apiProvider: "friendli", + apiModelId: "friendli-model", + }, + }) + ;(useExtensionState as any).mockImplementation(() => extensionState) + + rerender( + + + , + ) + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek") + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false) + }, 20000) + + it("does not trigger sync when mode has not changed", async () => { + const onDone = vi.fn() + let extensionState = createExtensionState({ + mode: "code", + apiConfiguration: { + apiProvider: "openai", + apiModelId: "gpt-4.1", + }, + }) + + ;(useExtensionState as any).mockImplementation(() => extensionState) + + const { rerender } = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") + }) + + // Make a dirty change so we can verify it isn't overwritten by a sync + fireEvent.click(screen.getByTestId("set-provider-baseten")) + expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten") + + // Re-render with a new extensionState identity but the same mode and config + // name. This makes the guard load-bearing because the effect is eligible to + // re-run from the extensionState dependency, but must not sync cachedState. + await act(async () => { + extensionState = createExtensionState({ + mode: "code", + apiConfiguration: { + apiProvider: "openai", + apiModelId: "gpt-4.1", + }, + }) + ;(useExtensionState as any).mockImplementation(() => extensionState) + + rerender( + + + , + ) + }) + + // Provider value should remain unchanged from the dirty state + expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten") + }, 20000) }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 6434eadb23..f4defb87dd 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -629,13 +629,13 @@ describe("SettingsView - Allowed Commands", () => { // Verify command was added expect(within(content).getByText("npm test")).toBeInTheDocument() - // Verify VSCode message was sent - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "updateSettings", - updatedSettings: { - allowedCommands: ["npm test"], - }, - }) + // Adding a command must NOT persist before Save; it only buffers in cachedState. + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ allowedCommands: ["npm test"] }), + }), + ) }) it("removes command from the list", () => { @@ -663,13 +663,13 @@ describe("SettingsView - Allowed Commands", () => { // Verify command was removed expect(within(content).queryByText("npm test")).not.toBeInTheDocument() - // Verify VSCode message was sent - expect(vscode.postMessage).toHaveBeenLastCalledWith({ - type: "updateSettings", - updatedSettings: { - allowedCommands: [], - }, - }) + // Removing a command must NOT persist before Save; it only buffers in cachedState. + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ allowedCommands: expect.anything() }), + }), + ) }) describe("SettingsView - Tab Navigation", () => { @@ -776,4 +776,46 @@ describe("SettingsView - Duplicate Commands", () => { }), ) }) + + it("reverts and does not persist allowed commands when discarding unsaved changes", () => { + // Render once and get the activateTab helper + const { onDone, activateTab, getSettingsContent } = renderSettingsView() + + // Activate the autoApprove tab + activateTab("autoApprove") + + const content = getSettingsContent() + // Enable always allow execute + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + // Add a command (buffers into cachedState, must not persist yet) + const input = within(content).getByTestId("command-input") + fireEvent.change(input, { target: { value: "npm test" } }) + const addButton = within(content).getByTestId("add-command-button") + fireEvent.click(addButton) + + // Verify the command was buffered and rendered before discarding it + expect(within(content).getByText("npm test")).toBeInTheDocument() + + // Click Done, which opens the unsaved-changes dialog since a change is detected + const doneButton = screen.getByText("settings:common.done") + fireEvent.click(doneButton) + + // Confirm discard + const discardButton = screen.getByTestId("alert-dialog-action") + fireEvent.click(discardButton) + + // Discarding must never persist the buffered command edit + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ allowedCommands: ["npm test"] }), + }), + ) + + // The buffered edit must be reverted before leaving Settings + expect(within(getSettingsContent()).queryByText("npm test")).not.toBeInTheDocument() + expect(onDone).toHaveBeenCalledTimes(1) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 567838cc30..88428a077d 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -4,13 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" import SettingsView from "../SettingsView" +import { vscode } from "@src/utils/vscode" -// Mock vscode API -const mockPostMessage = vi.fn() -const mockVscode = { - postMessage: mockPostMessage, -} -;(global as any).acquireVsCodeApi = () => mockVscode +const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => {}) // Mock the extension state context vi.mock("@src/context/ExtensionStateContext", () => ({ @@ -170,7 +166,14 @@ vi.mock("@src/components/modes/ModesView", () => ({ })) vi.mock("@src/components/mcp/McpView", () => ({ - default: () => null, + default: ({ mcpEnabled, setMcpEnabled }: any) => ( + + ), })) // Mock Tab components @@ -596,4 +599,33 @@ describe("SettingsView - Unsaved Changes Detection", () => { // No dialog should appear expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument() }) + + it("buffers MCP enablement until Save", async () => { + render( + + + , + ) + + const toggle = await screen.findByTestId("mcp-enabled-toggle") + fireEvent.click(toggle) + + await waitFor(() => expect(toggle).toHaveAttribute("data-mcp-enabled", "true")) + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ mcpEnabled: true }), + }), + ) + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + fireEvent.click(screen.getByTestId("save-button")) + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ mcpEnabled: true }), + }), + ) + }) }) 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/constants.ts b/webview-ui/src/components/settings/constants.ts index aa259b0efe..15061e333d 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -48,6 +48,7 @@ export const PROVIDERS = [ { value: "gemini", label: "Google Gemini", proxy: false }, { value: "deepseek", label: "DeepSeek", proxy: false }, { value: "moonshot", label: "Moonshot", proxy: false }, + { value: "kimi-code", label: "Kimi Code", proxy: false }, { value: "openai-native", label: "OpenAI", proxy: false }, { value: "openai-codex", label: "OpenAI - ChatGPT Plus/Pro", proxy: false }, { value: "openai", label: "OpenAI Compatible", proxy: true }, diff --git a/webview-ui/src/components/settings/providers/KimiCode.tsx b/webview-ui/src/components/settings/providers/KimiCode.tsx new file mode 100644 index 0000000000..4e9d3ff561 --- /dev/null +++ b/webview-ui/src/components/settings/providers/KimiCode.tsx @@ -0,0 +1,152 @@ +import { useEffect } from "react" +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + kimiCodeDefaultModelId, + kimiCodeModels, + type KimiCodeAuthMethod, + type ModelRecord, + type ProviderSettings, +} from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" + +import { ModelPicker } from "../ModelPicker" + +type KimiCodeProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void + kimiCodeIsAuthenticated?: boolean + kimiCodeOAuthState?: { + status: "idle" | "authorizing" | "polling" | "authenticated" | "error" + userCode?: string + verificationUri?: string + error?: string + } +} + +export const KimiCode = ({ + apiConfiguration, + setApiConfigurationField, + kimiCodeIsAuthenticated = false, + kimiCodeOAuthState, +}: KimiCodeProps) => { + const { t } = useAppTranslation() + const authMethod = apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const { data, refetch, isFetching } = useRouterModels({ + provider: "kimi-code", + enabled: authMethod === "oauth" ? kimiCodeIsAuthenticated : !!apiConfiguration.kimiCodeApiKey, + }) + const discoveredModels = data?.["kimi-code"] + const models: ModelRecord = + discoveredModels && Object.keys(discoveredModels).length > 0 ? discoveredModels : kimiCodeModels + + useEffect(() => { + if (authMethod === "oauth" && kimiCodeIsAuthenticated) void refetch() + }, [authMethod, kimiCodeIsAuthenticated, refetch]) + + const refreshModels = () => { + vscode.postMessage({ + type: "requestRouterModels", + values: { + provider: "kimi-code", + refresh: true, + kimiCodeAuthMethod: authMethod, + kimiCodeApiKey: apiConfiguration.kimiCodeApiKey, + }, + }) + void refetch() + } + + return ( +
+
+ + +
+ + {authMethod === "oauth" ? ( +
+ {kimiCodeIsAuthenticated ? ( +
+ + {t("settings:providers.kimiCode.authenticated")} + + +
+ ) : ( + + )} + {kimiCodeOAuthState?.status === "polling" && ( +
+

+ {t("settings:providers.kimiCode.deviceCodeHelp")} +

+ + {kimiCodeOAuthState.userCode} + + {kimiCodeOAuthState.verificationUri && ( + + {kimiCodeOAuthState.verificationUri} + + )} +
+ )} + {kimiCodeOAuthState?.status === "error" && ( +

{kimiCodeOAuthState.error}

+ )} +
+ ) : ( + + setApiConfigurationField("kimiCodeApiKey", (event.target as HTMLInputElement).value) + } + className="w-full" + data-testid="kimi-code-api-key"> + + + )} + + + + {t("settings:providers.kimiCode.docs")} +
+ ) +} diff --git a/webview-ui/src/components/settings/providers/Moonshot.tsx b/webview-ui/src/components/settings/providers/Moonshot.tsx index f9ec8e0272..2d6c7d849a 100644 --- a/webview-ui/src/components/settings/providers/Moonshot.tsx +++ b/webview-ui/src/components/settings/providers/Moonshot.tsx @@ -1,13 +1,22 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import { useQueryClient } from "@tanstack/react-query" -import type { ProviderSettings } from "@roo-code/types" +import type { ProviderSettings, ExtensionMessage } from "@roo-code/types" +import { moonshotDefaultModelId } from "@roo-code/types" + +import { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" +import { ModelPicker } from "../ModelPicker" +import { handleModelChangeSideEffects } from "../utils/providerModelConfig" +import type { ProviderName } from "@roo-code/types" import { inputEventTransform } from "../transforms" -import { cn } from "@/lib/utils" type MoonshotProps = { apiConfiguration: ProviderSettings @@ -15,8 +24,39 @@ type MoonshotProps = { simplifySettings?: boolean } -export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: MoonshotProps) => { +export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifySettings }: MoonshotProps) => { const { t } = useAppTranslation() + const { routerModels } = useExtensionState() + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() + const moonshotErrorJustReceived = useRef(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "singleRouterModelFetchResponse" && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === "moonshot" && refreshStatus === "loading") { + moonshotErrorJustReceived.current = true + setRefreshStatus("error") + setRefreshError(message.error) + } + } else if (message.type === "routerModels") { + if (refreshStatus === "loading") { + if (!moonshotErrorJustReceived.current) { + setRefreshStatus("success") + queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus, queryClient]) const handleInputChange = useCallback( ( @@ -29,6 +69,25 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + moonshotErrorJustReceived.current = false + setRefreshStatus("loading") + setRefreshError(undefined) + + const key = apiConfiguration.moonshotApiKey + + if (!key) { + setRefreshStatus("error") + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + vscode.postMessage({ + type: "requestRouterModels", + values: { moonshotApiKey: key, moonshotBaseUrl: apiConfiguration.moonshotBaseUrl }, + }) + }, [apiConfiguration, t]) + return ( <>
@@ -36,7 +95,7 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho + className="w-full"> api.moonshot.ai @@ -69,6 +128,45 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho )}
+ + handleModelChangeSideEffects("moonshot" as ProviderName, modelId, setApiConfigurationField) + } + /> + + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} ) } diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index 9d92ee972b..9b11c85369 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -1,5 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from "react" -import { useEvent } from "react-use" +import { useState, useCallback, useMemo, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { Checkbox } from "vscrui" @@ -7,6 +6,7 @@ import { type ProviderSettings, type ExtensionMessage, type ModelRecord, ollamaD import { useAppTranslation } from "@src/i18n/TranslationContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" +import { Button } from "@src/components/ui" import { vscode } from "@src/utils/vscode" import { inputEventTransform } from "../transforms" @@ -22,6 +22,9 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro const { t } = useAppTranslation() const [ollamaModels, setOllamaModels] = useState({}) + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() + const refreshStatusRef = useRef(refreshStatus) const routerModels = useRouterModels() const handleInputChange = useCallback( @@ -35,20 +38,42 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro [setApiConfigurationField], ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === "ollamaModels") { + if (!message.error) { + setOllamaModels(message.ollamaModels ?? {}) + } - switch (message.type) { - case "ollamaModels": - { - const newModels = message.ollamaModels ?? {} - setOllamaModels(newModels) + if (refreshStatusRef.current === "loading") { + const nextStatus = message.error ? "error" : "success" + refreshStatusRef.current = nextStatus + setRefreshStatus(nextStatus) + setRefreshError(message.error) } - break + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) } }, []) - useEvent("message", onMessage) + const handleRefreshModels = useCallback(() => { + refreshStatusRef.current = "loading" + setRefreshStatus("loading") + setRefreshError(undefined) + vscode.postMessage({ + type: "requestOllamaModels", + values: { + baseUrl: apiConfiguration?.ollamaBaseUrl, + apiKey: apiConfiguration?.ollamaApiKey, + }, + }) + }, [apiConfiguration?.ollamaBaseUrl, apiConfiguration?.ollamaApiKey]) // Refresh models on mount useEffect(() => { @@ -102,6 +127,33 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
)} + + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} { 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/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 755b272702..443335a79c 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -1,9 +1,23 @@ import React from "react" -import { type ProviderSettings, openAiCodexDefaultModelId, openAiCodexModels } from "@roo-code/types" +import { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, + type ProviderSettings, + openAiCodexDefaultModelId, + openAiCodexModels, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { Button } from "@src/components/ui" +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + StandardTooltip, +} from "@src/components/ui" import { vscode } from "@src/utils/vscode" import { ModelPicker } from "../ModelPicker" @@ -66,6 +80,35 @@ export const OpenAICodex: React.FC = ({ simplifySettings={simplifySettings} hidePricing /> + +
+
+ + + + +
+ +
) } diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 0524932c5f..8b11c128c7 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -6,7 +6,7 @@ import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, type ModelInfo, - type ReasoningEffort, + type ReasoningEffortExtended, type OrganizationAllowList, type ExtensionMessage, azureOpenAiDefaultApiVersion, @@ -266,13 +266,13 @@ export const OpenAICompatible = ({ setApiConfigurationField("openAiCustomModelInfo", { ...openAiCustomModelInfo, - reasoningEffort: value as ReasoningEffort, + reasoningEffort: value as ReasoningEffortExtended, }) } }} modelInfo={{ ...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], }} /> )} diff --git a/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx new file mode 100644 index 0000000000..76a2bfaddf --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/KimiCode.spec.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { KimiCode } from "../KimiCode" + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: { "kimi-code": {} }, refetch: vi.fn(), isFetching: false }), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + +describe("KimiCode settings", () => { + it("binds the API key input through the buffered settings setter", () => { + const setField = vi.fn() + render( + , + ) + fireEvent.input(screen.getByTestId("kimi-code-api-key"), { target: { value: "new-key" } }) + expect(setField).toHaveBeenCalledWith("kimiCodeApiKey", "new-key") + expect(screen.getByTestId("kimi-code-model-picker")).toBeInTheDocument() + }) + + it("shows device-code polling state", () => { + render( + , + ) + expect(screen.getByTestId("kimi-code-device-code")).toHaveTextContent("ABCD-EFGH") + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx new file mode 100644 index 0000000000..1ffcf9b683 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx @@ -0,0 +1,397 @@ +// npx vitest src/components/settings/providers/__tests__/Moonshot.spec.tsx + +import React from "react" +import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" +import type { ProviderSettings } from "@roo-code/types" + +import { Moonshot } from "../Moonshot" + +// Mock the translation hook +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock VSCode webview toolkit components using importOriginal +vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => ( +
+ {children} + onInput?.(e)} + placeholder={placeholder} + data-testid="moonshot-api-key-input" + /> +
+ ), + VSCodeDropdown: ({ children, value, onChange }: any) => ( +
+ {children} + +
+ ), + VSCodeOption: ({ children, value }: any) => ( +
+ {children} +
+ ), + } +}) + +// Mock the ModelPicker - must be a simple component that doesn't import anything +vi.mock("../ModelPicker", () => ({ + ModelPicker: function MockModelPicker() { + return React.createElement( + "div", + { "data-testid": "model-picker" }, + React.createElement("span", { "data-testid": "model-picker-default" }, "mock-default"), + React.createElement("span", { "data-testid": "model-picker-count" }, "0"), + ) + }, +})) + +// Mock vscode - factory must not reference outer scope variables +vi.mock("@src/utils/vscode", () => { + const mockPostMessage = vi.fn() + return { + vscode: { postMessage: mockPostMessage }, + } +}) + +// Mock useExtensionState +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn(), +})) + +// Mock react-use +vi.mock("react-use", () => ({ + useEvent: vi.fn(), +})) + +// Mock useRouterModels +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: {}, isLoading: false, error: null }), +})) + +// Mock @src/components/ui using importOriginal to get all real exports +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), + } +}) + +// Mock VSCodeButtonLink +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ href, children }: any) => ( + + {children} + + ), +})) + +// Mock handleModelChangeSideEffects +vi.mock("../utils/providerModelConfig", () => ({ + handleModelChangeSideEffects: vi.fn(), +})) + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +const mockUseExtensionState = useExtensionState as ReturnType + +describe("Moonshot Component", () => { + const mockSetApiConfigurationField = vi.fn() + + const createDefaultApiConfiguration = (overrides?: Partial): ProviderSettings => ({ + apiProvider: "moonshot", + moonshotBaseUrl: "https://api.moonshot.ai/v1", + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + }) + + it("renders API key input, base URL dropdown, and refresh button", () => { + render( + , + ) + + expect(screen.getByTestId("moonshot-api-key-input")).toBeInTheDocument() + expect(screen.getByTestId("vscode-dropdown")).toBeInTheDocument() + // Use getAllByTestId and filter by variant to find the refresh button + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline") + expect(refreshButton).toBeInTheDocument() + }) + + it("refresh button is disabled when no API key is provided", () => { + render( + , + ) + + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline") + expect(refreshButton).toBeDisabled() + }) + + it("refresh button is enabled when API key is provided", () => { + render( + , + ) + + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline") + expect(refreshButton).not.toBeDisabled() + }) + + it("shows loading state when refresh is clicked", () => { + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + + render( + , + ) + + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + + // After click, the button should be disabled (loading state) + expect(refreshButton).toBeDisabled() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("shows success state after routerModels message received", async () => { + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + + render( + , + ) + + // Click refresh to enter loading state + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + + // Simulate receiving routerModels message (without prior error) + window.postMessage( + { + type: "routerModels", + values: { moonshot: { "kimi-k2-0905-preview": { maxTokens: 16384 } } }, + }, + "*", + ) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + }) + + it("shows error state after singleRouterModelFetchResponse error message", async () => { + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + + render( + , + ) + + // Click refresh to enter loading state + await act(async () => { + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline") + fireEvent.click(refreshButton!) + }) + + // Wait for loading state to render and effect to re-run with new refreshStatus + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + // Simulate error message — use setTimeout to ensure the useEffect has + // re-run with the updated refreshStatus === "loading" closure before the + // message event fires, otherwise the handler closure still captures "idle". + await act(async () => { + return new Promise((resolve) => { + setTimeout(() => { + window.postMessage( + { + type: "singleRouterModelFetchResponse", + success: false, + error: "API connection failed", + values: { provider: "moonshot" }, + }, + "*", + ) + resolve(undefined) + }, 0) + }) + }) + + // The component displays refreshError ("API connection failed") when set, + // falling back to the generic message only when refreshError is undefined. + await waitFor(() => { + expect(screen.getByText("API connection failed")).toBeInTheDocument() + }) + }) + + it("race condition: error arrives before routerModels success — stays in error state", async () => { + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + + render( + , + ) + + // Click refresh to enter loading state + await act(async () => { + const buttons = screen.getAllByTestId("button") + const btn = buttons.find((b) => b.getAttribute("data-variant") === "outline") + fireEvent.click(btn!) + }) + + // Wait for loading state to render + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + // Error arrives first — delay with setTimeout so the handler closure + // captures refreshStatus === "loading". + await act(async () => { + return new Promise((resolve) => { + setTimeout(() => { + window.postMessage( + { + type: "singleRouterModelFetchResponse", + success: false, + error: "API connection failed", + values: { provider: "moonshot" }, + }, + "*", + ) + resolve(undefined) + }, 0) + }) + }) + + // The component displays refreshError ("API connection failed") when set. + await waitFor(() => { + expect(screen.getByText("API connection failed")).toBeInTheDocument() + }) + + // Then routerModels arrives — should NOT flip to success because error was received + await act(async () => { + window.postMessage( + { + type: "routerModels", + values: { moonshot: { "kimi-k2-0905-preview": { maxTokens: 16384 } } }, + }, + "*", + ) + }) + + // Error state should still be present (not flipped to success) + await waitFor(() => { + expect(screen.getByText("API connection failed")).toBeInTheDocument() + }) + }) + + it("sends correct message when refresh is clicked", () => { + mockUseExtensionState.mockReturnValue({ + routerModels: {}, + }) + + render( + , + ) + + const buttons = screen.getAllByTestId("button") + const refreshButton = buttons.find((b) => b.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestRouterModels", + values: { + moonshotApiKey: "test-key", + moonshotBaseUrl: "https://api.moonshot.cn/v1", + }, + }), + ) + }) + + it("shows 'Get Moonshot API Key' link when no API key is set", () => { + render( + , + ) + + expect(screen.getByTestId("vscode-button-link")).toBeInTheDocument() + expect(screen.getByTestId("vscode-button-link")).toHaveAttribute( + "href", + "https://platform.moonshot.ai/console/api-keys", + ) + }) + + it("hides 'Get Moonshot API Key' link when API key is set", () => { + render( + , + ) + + expect(screen.queryByTestId("vscode-button-link")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx index 91025ee291..a4e14b4d81 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx @@ -1,7 +1,7 @@ // npx vitest src/components/settings/providers/__tests__/Ollama.spec.tsx import React from "react" -import { render, screen, fireEvent } from "@/utils/test-utils" +import { render, screen, fireEvent, act } from "@/utils/test-utils" import { Ollama } from "../Ollama" import { ProviderSettings } from "@roo-code/types" @@ -45,7 +45,7 @@ vi.mock("@src/i18n/TranslationContext", () => ({ // Mock the ModelPicker vi.mock("../../ModelPicker", () => ({ - ModelPicker: () =>
Model Picker
, + ModelPicker: ({ models }: any) =>
{Object.keys(models ?? {}).join(",")}
, })) // Mock the ThinkingBudget @@ -57,21 +57,33 @@ vi.mock("../../ThinkingBudget", () => ({ ), })) -// Mock react-use -vi.mock("react-use", () => ({ - useEvent: vi.fn(), -})) - // Mock useRouterModels vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ useRouterModels: () => ({ data: {}, isLoading: false, error: null }), })) +const { postMessageMock } = vi.hoisted(() => ({ + postMessageMock: vi.fn(), +})) + // Mock vscode vi.mock("@src/utils/vscode", () => ({ - vscode: { postMessage: vi.fn() }, + vscode: { postMessage: postMessageMock }, })) +// Stub the shared Button so we can assert onClick/disabled without its styling deps. +vi.mock("@src/components/ui", async () => { + const actual = await vi.importActual("@src/components/ui") + return { + ...actual, + Button: ({ children, onClick, disabled, className }: any) => ( + + ), + } +}) + describe("Ollama Component - thinking setting", () => { const mockSetApiConfigurationField = vi.fn() @@ -221,3 +233,167 @@ describe("Ollama Component - thinking setting", () => { expect(screen.queryByTestId("thinking-budget")).toBeNull() }) }) + +describe("Ollama Component - refresh models", () => { + const mockSetApiConfigurationField = vi.fn() + + const dispatchMessage = (data: any) => + act(() => { + window.dispatchEvent(new MessageEvent("message", { data })) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the refresh button in idle state", () => { + render( + , + ) + + const button = screen.getByTestId("refresh-button") + expect(button).not.toBeDisabled() + expect(button.querySelector(".codicon-refresh")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.label")).toBeInTheDocument() + }) + + it("sends requestOllamaModels with baseUrl and apiKey when the refresh button is clicked", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + + expect(postMessageMock).toHaveBeenCalledWith({ + type: "requestOllamaModels", + values: { + baseUrl: "https://ollama.example.com", + apiKey: "secret-key", + }, + }) + }) + + it("enters loading state and disables the button while refreshing", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + + const button = screen.getByTestId("refresh-button") + expect(button).toBeDisabled() + expect(button.querySelector(".codicon-loading")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("handles a response dispatched synchronously while posting the refresh request", () => { + render( + , + ) + + // Ignore the mount request and respond synchronously to the explicit refresh. + postMessageMock.mockClear() + postMessageMock.mockImplementationOnce(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: "ollamaModels", ollamaModels: { "llama3:latest": {} } } }), + ) + }) + fireEvent.click(screen.getByTestId("refresh-button")) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveTextContent("llama3:latest") + }) + + it("shows success state when ollamaModels arrives with models while loading", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "ollamaModels", ollamaModels: { "llama3:latest": {} } }) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + + it("shows success state when Ollama is reachable but has no installed models", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "ollamaModels", ollamaModels: {} }) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + + it("displays the backend error message when ollamaModels arrives with an error", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "ollamaModels", ollamaModels: {}, error: "Connection refused" }) + + expect(screen.getByText("Connection refused")).toBeInTheDocument() + }) + + it("preserves previously loaded models when a later refresh fails", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "ollamaModels", ollamaModels: { "llama3:latest": {} } }) + expect(screen.getByTestId("model-picker")).toHaveTextContent("llama3:latest") + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "ollamaModels", ollamaModels: {}, error: "Connection refused" }) + + expect(screen.getByText("Connection refused")).toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveTextContent("llama3:latest") + }) + + it("ignores ollamaModels messages when not in loading state", () => { + render( + , + ) + + // No refresh initiated; an unsolicited ollamaModels message should be a no-op. + dispatchMessage({ type: "ollamaModels", ollamaModels: { "llama3:latest": {} } }) + + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) +}) 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/settings/providers/__tests__/OpenAICodex.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx new file mode 100644 index 0000000000..d73120ada3 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx @@ -0,0 +1,97 @@ +import React from "react" + +import { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, + providerIdentifiers, + type ProviderSettings, +} from "@roo-code/types" + +import { fireEvent, render, screen } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" + +import { OpenAICodex } from "../OpenAICodex" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => + ({ + "settings:openAiCodexSpeed.label": "Speed", + "settings:openAiCodexSpeed.tooltip": + "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", + "settings:openAiCodexSpeed.standard": "Standard", + "settings:openAiCodexSpeed.fast": "Fast (1.5x speed, increased usage)", + })[key] ?? key, + }), +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + Select: ({ children, value, onValueChange }: any) => ( + + ), + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + StandardTooltip: ({ children, content }: any) => {children}, +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + +vi.mock("../OpenAICodexRateLimitDashboard", () => ({ + OpenAICodexRateLimitDashboard: () => null, +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { postMessage: vi.fn() }, +})) + +describe("OpenAICodex speed selector", () => { + const renderSelector = (apiConfiguration: ProviderSettings, setApiConfigurationField = vi.fn()) => { + render() + return { setApiConfigurationField, selector: screen.getByRole("combobox", { name: "Speed" }) } + } + + it("defaults to Standard and clearly explains the Fast quota trade-off", () => { + const { selector } = renderSelector({ apiProvider: providerIdentifiers.openaiCodex }) + + expect(selector).toHaveValue(OpenAiCodexServiceTier.Default) + expect(screen.getByRole("option", { name: "Standard" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "Fast (1.5x speed, increased usage)" })).toBeInTheDocument() + expect( + screen.getByTitle( + "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", + ), + ).toBeInTheDocument() + }) + + it("selects Fast from a saved preference and persists changes through the settings callback", () => { + const { selector, setApiConfigurationField } = renderSelector({ + apiProvider: providerIdentifiers.openaiCodex, + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + const postMessage = vi.mocked(vscode.postMessage) + + expect(selector).toHaveValue(OpenAiCodexServiceTier.Priority) + + fireEvent.change(selector, { target: { value: OpenAiCodexServiceTier.Default } }) + expect(setApiConfigurationField).toHaveBeenLastCalledWith( + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier.Default, + ) + + fireEvent.change(selector, { target: { value: OpenAiCodexServiceTier.Priority } }) + expect(setApiConfigurationField).toHaveBeenLastCalledWith( + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier.Priority, + ) + expect(postMessage).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index aba81ec219..3da109efc2 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -76,8 +76,13 @@ vi.mock("../../R1FormatSetting", () => ({ R1FormatSetting: () =>
R1 Format Setting
, })) +const { mockThinkingBudget } = vi.hoisted(() => ({ mockThinkingBudget: vi.fn() })) + vi.mock("../../ThinkingBudget", () => ({ - ThinkingBudget: () =>
Thinking Budget
, + ThinkingBudget: (props: any) => { + mockThinkingBudget(props) + return
Thinking Budget
+ }, })) // Mock react-use @@ -312,4 +317,39 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => { expect(description).toHaveClass("text-sm", "text-vscode-descriptionForeground", "ml-6") }) }) + describe("reasoning effort", () => { + it("should expose and persist the max reasoning-effort value", () => { + const apiConfiguration: Partial = { + enableReasoningEffort: true, + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + }, + } + + render( + , + ) + + const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] + expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]) + + thinkingBudgetProps.setApiConfigurationField("reasoningEffort", "max") + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openAiCustomModelInfo", { + ...apiConfiguration.openAiCustomModelInfo, + reasoningEffort: "max", + }) + }) + }) }) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 6a990c37a5..8d725efed2 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -5,6 +5,7 @@ export { Gemini } from "./Gemini" export { LMStudio } from "./LMStudio" export { Mistral } from "./Mistral" export { Moonshot } from "./Moonshot" +export { KimiCode } from "./KimiCode" export { Ollama } from "./Ollama" export { OpenAI } from "./OpenAI" export { OpenAICodex } from "./OpenAICodex" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index d4c87ec02c..da660976f4 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -4,6 +4,7 @@ import { bedrockDefaultModelId, deepSeekDefaultModelId, moonshotDefaultModelId, + kimiCodeDefaultModelId, geminiDefaultModelId, mistralDefaultModelId, openRouterDefaultModelId, @@ -42,6 +43,7 @@ export const PROVIDER_SERVICE_CONFIG: Partial> = bedrock: bedrockDefaultModelId, deepseek: deepSeekDefaultModelId, moonshot: moonshotDefaultModelId, + "kimi-code": kimiCodeDefaultModelId, gemini: geminiDefaultModelId, mistral: mistralDefaultModelId, "openai-native": openAiNativeDefaultModelId, @@ -117,6 +120,7 @@ const PROVIDER_MODEL_CONFIG: Partial> gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, + "kimi-code": { field: "apiModelId", default: kimiCodeDefaultModelId }, minimax: { field: "apiModelId", default: minimaxDefaultModelId }, mimo: { field: "apiModelId", default: mimoDefaultModelId }, mistral: { field: "apiModelId", default: mistralDefaultModelId }, @@ -201,11 +205,13 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "unbound", "openai", // OpenAI Compatible "openai-codex", // OpenAI Codex has custom UI with auth and rate limits + "kimi-code", "litellm", "vercel-ai-gateway", "ollama", "lmstudio", "vscode-lm", + "moonshot", // Moonshot has custom ModelPicker inside Moonshot.tsx ] /** 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 9f107b3841..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, @@ -20,6 +21,10 @@ import { openRouterDefaultModelId, vscodeLlmModels, vscodeLlmDefaultModelId, + moonshotDefaultModelId, + moonshotModels, + kimiCodeDefaultModelInfo, + providerIdentifiers, } from "@roo-code/types" import { useSelectedModel } from "../useSelectedModel" @@ -85,7 +90,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", openRouterSpecificProvider: "test-provider", } @@ -146,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", } @@ -209,7 +214,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", openRouterSpecificProvider: "test-provider", } @@ -261,7 +266,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "test-model", } @@ -302,7 +307,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "openrouter", + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "non-existent-model", openRouterSpecificProvider: "non-existent-provider", } @@ -402,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() }) @@ -425,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, } @@ -438,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", () => { @@ -461,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, } @@ -475,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, } @@ -489,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, } @@ -523,7 +561,7 @@ describe("useSelectedModel", () => { it("should enable supportsPromptCache for custom-arn model", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn", } @@ -536,7 +574,7 @@ describe("useSelectedModel", () => { it("should enable supportsImages for custom-arn model", () => { const apiConfiguration: ProviderSettings = { - apiProvider: "bedrock", + apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn", } @@ -569,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 @@ -595,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) @@ -630,7 +668,7 @@ describe("useSelectedModel", () => { } as any) const apiConfiguration: ProviderSettings = { - apiProvider: "litellm", + apiProvider: providerIdentifiers.litellm, litellmModelId: "my-custom-model", } @@ -675,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 @@ -711,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) }) @@ -756,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) }) @@ -781,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 @@ -817,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) }) @@ -841,7 +879,7 @@ describe("useSelectedModel", () => { } const apiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "custom-model", openAiCustomModelInfo: customModelInfo, } @@ -849,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) }) @@ -863,7 +901,7 @@ describe("useSelectedModel", () => { } const apiConfiguration: ProviderSettings = { - apiProvider: "openai", + apiProvider: providerIdentifiers.openai, openAiModelId: "custom-model-no-tools", openAiCustomModelInfo: customModelInfo, } @@ -871,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) }) @@ -898,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"]) }) @@ -946,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) @@ -965,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 @@ -981,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" }, } @@ -1016,29 +1054,168 @@ 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({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + moonshot: {}, + }, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + + it("should return default moonshot model when no custom model is specified", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.moonshot, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.moonshot) + expect(result.current.id).toBe(moonshotDefaultModelId) + expect(result.current.info).toEqual(moonshotModels[moonshotDefaultModelId]) + }) + + it("should use router model override when routerModels.moonshot has the model", () => { + const routerModelInfo: ModelInfo = { + maxTokens: 32000, + contextWindow: 262144, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 1.0, + outputPrice: 5.0, + } + + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + moonshot: { + "kimi-k2-0905-preview": routerModelInfo, + }, + }, + isLoading: false, + isError: false, + } as any) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.moonshot, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(moonshotDefaultModelId) + // Router info takes precedence over static info + expect(result.current.info).toEqual(routerModelInfo) + }) + + it("should fallback to default when model ID is not in static or router models", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.moonshot, + apiModelId: "non-existent-model", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(moonshotDefaultModelId) + expect(result.current.info).toEqual(moonshotModels[moonshotDefaultModelId]) + }) + + it("should use getValidatedModelId to return apiModelId when valid", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.moonshot, + apiModelId: "kimi-k2-turbo-preview", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("kimi-k2-turbo-preview") + expect(result.current.info).toEqual(moonshotModels["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 62a30a26b2..ec513ce885 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -26,6 +26,7 @@ import { friendliModels, basetenModels, qwenCodeModels, + kimiCodeDefaultModelInfo, litellmDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, @@ -35,6 +36,7 @@ import { isDynamicProvider, isRetiredProvider, getProviderDefaultModelId, + providerIdentifiers, } from "@roo-code/types" import { useRouterModels } from "./useRouterModels" @@ -103,7 +105,12 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined, ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined, }) - : { id: getProviderDefaultModelId(activeProvider ?? "openrouter"), info: undefined } + : activeProvider === "kimi-code" && apiConfiguration + ? { + id: apiConfiguration.apiModelId || getProviderDefaultModelId("kimi-code"), + info: kimiCodeDefaultModelInfo, + } + : { id: getProviderDefaultModelId(activeProvider ?? "openrouter"), info: undefined } return { provider, @@ -142,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 @@ -158,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 @@ -181,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] @@ -215,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] @@ -238,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 @@ -252,22 +259,32 @@ function getSelectedModel({ const staticInfo = deepSeekModels[id as keyof typeof deepSeekModels] return { id, info: routerInfo ?? staticInfo } } - case "moonshot": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] - return { id, info } + case providerIdentifiers.moonshot: { + const availableModels = routerModels.moonshot + ? { ...moonshotModels, ...routerModels.moonshot } + : moonshotModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels.moonshot?.[id] + const staticInfo = moonshotModels[id as keyof typeof moonshotModels] + return { id, info: routerInfo ?? staticInfo } } - case "minimax": { + case providerIdentifiers.kimiCode: { + const configuredId = apiConfiguration.apiModelId + const availableModels = routerModels["kimi-code"] + const id = configuredId || defaultModelId + return { id, info: availableModels?.[id] ?? kimiCodeDefaultModelInfo } + } + 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 }) @@ -275,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!] @@ -307,7 +324,7 @@ function getSelectedModel({ info: adjustedInfo || undefined, } } - case "lmstudio": { + case providerIdentifiers.lmstudio: { const id = apiConfiguration.lmStudioModelId ?? "" const modelInfo = lmStudioModels && lmStudioModels[apiConfiguration.lmStudioModelId!] return { @@ -315,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 @@ -335,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"], @@ -374,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"], @@ -385,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"], @@ -401,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" || @@ -445,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__/RooHero.visual.tsx b/webview-ui/src/components/welcome/__tests__/RooHero.visual.tsx new file mode 100644 index 0000000000..8b9c8b46dd --- /dev/null +++ b/webview-ui/src/components/welcome/__tests__/RooHero.visual.tsx @@ -0,0 +1,29 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import RooHero from "../RooHero" + +async function waitForAssetsAndRender(component: any) { + await component.evaluate(async () => { + await document.fonts.ready + const images = Array.from(document.querySelectorAll("img")) + await Promise.all( + images.map((img) => { + if (img.complete) return Promise.resolve() + return new Promise((resolve) => { + img.onload = resolve + img.onerror = resolve + }) + }), + ) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) +} + +test("renders the welcome hero in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + await waitForAssetsAndRender(component) + + await expect(component).toHaveScreenshot("zoo-hero-dark.png") +}) 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 new file mode 100644 index 0000000000..3a99cfb487 Binary files /dev/null and b/webview-ui/src/components/welcome/__tests__/__screenshots__/zoo-hero-dark.png differ diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 29b958319c..d0d4e37afa 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -317,7 +317,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode case "state": { const newState = message.state ?? {} setState((prevState) => mergeExtensionState(prevState, newState)) - setShowWelcome(!checkExistKey(newState.apiConfiguration)) + setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message if ((newState as any).alwaysAllowFollowupQuestions !== undefined) { diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 6e78f21ef2..11ac1d4028 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar context de forma intel·ligent", "openApiHistory": "Obrir historial d'API", "openUiHistory": "Obrir historial d'UI", - "backToParentTask": "Tasca principal" + "backToParentTask": "Tasca principal", + "waitingOnSubtask": "Esperant subtasca", + "goToSubtask": "Anar a la subtasca" }, "unpin": "Desfixar", "pin": "Fixar", @@ -185,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", @@ -195,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", @@ -341,9 +343,9 @@ "support": "Dona suport a Zoo Code fent-nos una estrella a GitHub.", "release": { "heading": "Novetats:", - "highlight1": "Família OpenAI GPT-5.6 (Sol, Terra, Luna) — disponible tant a OpenAI Codex com a OpenAI Native.", - "highlight2": "Suport per a Grok 4.5 — el nou model insígnia de xAI, més una correcció del reasoning effort que també beneficia Grok 4 Mini.", - "highlight3": "Proveïdor Kenari — un gateway d'IA de primera classe compatible amb OpenAI, facturat en rupies, que cobreix Claude, GPT, DeepSeek, GLM, Kimi i més." + "highlight1": "Proveïdors Moonshot i Kimi Code — connecta't als models de Moonshot amb detecció de models en temps real o inicia la sessió a Kimi Code mitjançant el flux de dispositiu OAuth.", + "highlight2": "Compatibilitat amb els models més recents — utilitza Claude Opus 5 amb tots els proveïdors, a més de Kimi K3, Gemini 3.6 Flash i MiniMax-M3.", + "highlight3": "Fluxos de treball més fiables — abandona subtasques interrompudes de manera neta i indexa fitxers Dart i de text pla a la teva base de codi." }, "cloudAgents": { "heading": "Novetats al núvol:", diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index ab1bba7582..a872651d23 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Subtasca", "deleteWithSubtasks": "Això també eliminarà {{count}} subtasca(s). Estàs segur?", "expandSubtasks": "Expandir subtasques", - "collapseSubtasks": "Contreure subtasques" + "collapseSubtasks": "Contreure subtasques", + "delegatedTag": "Esperant subtasca", + "interruptedTag": "Interrompuda" } diff --git a/webview-ui/src/i18n/locales/ca/mcp.json b/webview-ui/src/i18n/locales/ca/mcp.json index 0bda1f751a..6e9e958a58 100644 --- a/webview-ui/src/i18n/locales/ca/mcp.json +++ b/webview-ui/src/i18n/locales/ca/mcp.json @@ -8,11 +8,6 @@ "title": "Activa els servidors MCP", "description": "Activa-ho perquè Zoo pugui utilitzar eines dels servidors MCP connectats. Això dóna més capacitats a Zoo. Si no vols utilitzar aquestes eines addicionals, desactiva-ho per ajudar a reduir el cost dels tokens API." }, - "enableServerCreation": { - "title": "Activa la creació de servidors MCP", - "description": "Activa-ho perquè Zoo t'ajudi a crear <1>nous servidors MCP personalitzats. <0>Més informació sobre la creació de servidors", - "hint": "Consell: Per reduir el cost dels tokens API, desactiva aquesta opció quan no demanis a Zoo que creï un nou servidor MCP." - }, "editGlobalMCP": "Edita MCP global", "editProjectMCP": "Edita MCP del projecte", "learnMoreEditingSettings": "Més informació sobre com editar fitxers de configuració MCP", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 4cb1e90027..7c0454f55c 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Clau API de Moonshot", "getMoonshotApiKey": "Obtenir clau API de Moonshot", "moonshotBaseUrl": "Punt d'entrada de Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Clau API de Z AI", "getZaiApiKey": "Obtenir clau API de Z AI", "zaiEntrypoint": "Punt d'entrada de Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Limitar el nombre màxim de tokens en la resposta", "maxOutputTokensLabel": "Tokens màxims de sortida", "maxTokensGenerateDescription": "Tokens màxims a generar en la resposta", + "openAiCodexSpeed": { + "label": "Velocitat", + "tooltip": "El mode ràpid utilitza el processament prioritari de Codex per oferir aproximadament 1,5 vegades més velocitat i consumeix més quota de subscripció.", + "standard": "Estàndard", + "fast": "Ràpid (1,5 vegades més velocitat, més consum)" + }, "serviceTier": { "label": "Nivell de servei", "tooltip": "Per a un processament més ràpid de les sol·licituds de l'API, proveu el nivell de servei de processament prioritari. Per a preus més baixos amb una latència més alta, proveu el nivell de processament flexible.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index a9d3472229..38415c87a5 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Kontext intelligent komprimieren", "openApiHistory": "API-Verlauf öffnen", "openUiHistory": "UI-Verlauf öffnen", - "backToParentTask": "Übergeordnete Aufgabe" + "backToParentTask": "Übergeordnete Aufgabe", + "waitingOnSubtask": "Wartet auf Unteraufgabe", + "goToSubtask": "Zur Unteraufgabe" }, "unpin": "Lösen von oben", "pin": "Anheften", @@ -128,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" }, @@ -185,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", @@ -196,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", @@ -286,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" @@ -341,9 +343,9 @@ "support": "Bitte unterstütze Zoo Code, indem du uns auf GitHub einen Stern gibst.", "release": { "heading": "Was ist neu:", - "highlight1": "OpenAI-GPT-5.6-Familie (Sol, Terra, Luna) — verfügbar über OpenAI Codex und OpenAI Native.", - "highlight2": "Grok-4.5-Unterstützung — xAIs neues Flaggschiff-Modell, plus ein Fix für den Reasoning Effort, von dem auch Grok 4 Mini profitiert.", - "highlight3": "Kenari-Anbieter — ein erstklassiges OpenAI-kompatibles KI-Gateway, das in Rupiah abgerechnet wird und Claude, GPT, DeepSeek, GLM, Kimi und mehr abdeckt." + "highlight1": "Moonshot- und Kimi-Code-Anbieter — verbinde dich dank Live-Modellerkennung mit Moonshot-Modellen oder melde dich über den OAuth-Gerätefluss bei Kimi Code an.", + "highlight2": "Unterstützung für die neuesten Modelle — nutze Claude Opus 5 anbieterübergreifend sowie Kimi K3, Gemini 3.6 Flash und MiniMax-M3.", + "highlight3": "Zuverlässigere Workflows — brich unterbrochene Unteraufgaben sauber ab und indiziere Dart- und Klartextdateien in deiner Codebasis." }, "cloudAgents": { "heading": "Neu in der Cloud:", diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index 46064d6e2e..b10fcd445e 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Teilaufgabe", "deleteWithSubtasks": "Dies löscht auch {{count}} Teilaufgabe(n). Bist du sicher?", "expandSubtasks": "Teilaufgaben erweitern", - "collapseSubtasks": "Teilaufgaben einklappen" + "collapseSubtasks": "Teilaufgaben einklappen", + "delegatedTag": "Wartet auf Unteraufgabe", + "interruptedTag": "Unterbrochen" } diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index a8aac6ee37..c8872d4de3 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -8,11 +8,6 @@ "title": "MCP-Server aktivieren", "description": "Schalte dies EIN, damit Zoo Tools von verbundenen MCP-Servern verwenden kann. Dies gibt Zoo mehr Möglichkeiten. Wenn du diese zusätzlichen Tools nicht verwenden möchtest, schalte es AUS, um API-Token-Kosten zu senken." }, - "enableServerCreation": { - "title": "MCP-Server-Erstellung aktivieren", - "description": "Aktiviere dies, damit Zoo dir helfen kann, <1>neue benutzerdefinierte MCP-Server zu erstellen. <0>Erfahre mehr über Server-Erstellung", - "hint": "Hinweis: Um API-Token-Kosten zu senken, deaktiviere diese Einstellung, wenn du Zoo nicht aktiv darum bittest, einen neuen MCP-Server zu erstellen." - }, "editGlobalMCP": "Globale MCP bearbeiten", "editProjectMCP": "Projekt-MCP bearbeiten", "learnMoreEditingSettings": "Mehr über das Bearbeiten von MCP-Einstellungsdateien erfahren", 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 8566410041..504bb56cba 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-Schlüssel", "getMoonshotApiKey": "Moonshot API-Schlüssel erhalten", "moonshotBaseUrl": "Moonshot-Einstiegspunkt", + "kimiCode": { + "authMethod": "Authentifizierungsmethode", + "oauth": "Kimi Code-Abonnement (OAuth)", + "apiKey": "Kimi Code-API-Schlüssel", + "apiKeyLabel": "Kimi Code-API-Schlüssel", + "signIn": "Bei Kimi Code anmelden", + "signOut": "Abmelden", + "authenticated": "Bei Kimi Code angemeldet", + "deviceCodeHelp": "Gib diesen Gerätecode auf der Kimi-Autorisierungsseite ein:", + "docs": "Kimi Code-Dokumentation" + }, "zaiApiKey": "Z AI API-Schlüssel", "getZaiApiKey": "Z AI API-Schlüssel erhalten", "zaiEntrypoint": "Z AI Einstiegspunkt", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Begrenze die maximale Anzahl von Tokens in der Antwort", "maxOutputTokensLabel": "Maximale Ausgabe-Tokens", "maxTokensGenerateDescription": "Maximale Tokens, die in der Antwort generiert werden", + "openAiCodexSpeed": { + "label": "Geschwindigkeit", + "tooltip": "Der schnelle Modus nutzt die priorisierte Codex-Verarbeitung für etwa 1,5-fache Geschwindigkeit und verbraucht mehr von deinem Abonnementkontingent.", + "standard": "Standard", + "fast": "Schnell (1,5-fache Geschwindigkeit, höherer Verbrauch)" + }, "serviceTier": { "label": "Service-Stufe", "tooltip": "Für eine schnellere Verarbeitung von API-Anfragen, probiere die Prioritäts-Verarbeitungsstufe. Für niedrigere Preise bei höherer Latenz, probiere die Flex-Verarbeitungsstufe.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index f3e2d85565..8df0c2938a 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -17,7 +17,9 @@ "delete": "Delete Task (Shift + Click to skip confirmation)", "openApiHistory": "Open API History", "openUiHistory": "Open UI History", - "backToParentTask": "Parent task" + "backToParentTask": "Parent task", + "waitingOnSubtask": "Waiting on subtask", + "goToSubtask": "Go to subtask" }, "unpin": "Unpin", "pin": "Pin", @@ -360,9 +362,9 @@ "support": "Please support Zoo Code by starring us on GitHub.", "release": { "heading": "What's New:", - "highlight1": "OpenAI GPT-5.6 family (Sol, Terra, Luna) — available across both the OpenAI Codex and OpenAI Native provider paths.", - "highlight2": "Grok 4.5 support — xAI's new flagship model, plus a reasoning-effort fix that also benefits Grok 4 Mini.", - "highlight3": "Kenari provider — a first-class OpenAI-compatible AI gateway billed in Rupiah, covering Claude, GPT, DeepSeek, GLM, Kimi and more." + "highlight1": "Moonshot and Kimi Code providers — connect to Moonshot models with live model discovery, or sign in to Kimi Code through its OAuth device flow.", + "highlight2": "Latest model support — use Claude Opus 5 across providers, plus Kimi K3, Gemini 3.6 Flash, and MiniMax-M3.", + "highlight3": "More reliable workflows — abandon interrupted subtasks cleanly, and index Dart and plain-text files in your codebase." }, "cloudAgents": { "heading": "New in the Cloud:", diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 85174890e1..6d53cd3663 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "This will also delete {{count}} subtask(s). Are you sure?", "expandSubtasks": "Expand subtasks", - "collapseSubtasks": "Collapse subtasks" + "collapseSubtasks": "Collapse subtasks", + "delegatedTag": "Waiting on subtask", + "interruptedTag": "Interrupted" } diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index 9256146311..f0c033fe74 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -8,11 +8,6 @@ "title": "Enable MCP Servers", "description": "Turn this ON to let Zoo use tools from connected MCP servers. This gives Zoo more capabilities. If you don't plan to use these extra tools, turn it OFF to help reduce API token costs." }, - "enableServerCreation": { - "title": "Enable MCP Server Creation", - "description": "Enable this to have Zoo help you build <1>new custom MCP servers. <0>Learn about server creation", - "hint": "Hint: To reduce API token costs, disable this setting when you are not actively asking Zoo to create a new MCP server." - }, "editGlobalMCP": "Edit Global MCP", "editProjectMCP": "Edit Project MCP", "refreshMCP": "Refresh MCP Servers", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 9ef634f866..3d84065849 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -539,6 +539,17 @@ "moonshotApiKey": "Moonshot API Key", "getMoonshotApiKey": "Get Moonshot API Key", "moonshotBaseUrl": "Moonshot Entrypoint", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API Key", "getMiniMaxApiKey": "Get MiniMax API Key", "minimaxBaseUrl": "MiniMax Entrypoint", @@ -1115,6 +1126,12 @@ "limitMaxTokensDescription": "Limit the maximum number of tokens in the response", "maxOutputTokensLabel": "Max output tokens", "maxTokensGenerateDescription": "Maximum tokens to generate in response", + "openAiCodexSpeed": { + "label": "Speed", + "tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", + "standard": "Standard", + "fast": "Fast (1.5x speed, increased usage)" + }, "serviceTier": { "label": "Service tier", "tooltip": "For faster processing of API requests, try the priority processing service tier. For lower prices with higher latency, try the flex processing tier.", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 9111e34430..d3beb38fe0 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar contexto de forma inteligente", "openApiHistory": "Abrir historial de API", "openUiHistory": "Abrir historial de UI", - "backToParentTask": "Tarea principal" + "backToParentTask": "Tarea principal", + "waitingOnSubtask": "Esperando subtarea", + "goToSubtask": "Ir a la subtarea" }, "unpin": "Desfijar", "pin": "Fijar", @@ -185,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", @@ -195,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", @@ -341,9 +343,9 @@ "support": "Apoya a Zoo Code dándonos una estrella en GitHub.", "release": { "heading": "Novedades:", - "highlight1": "Familia OpenAI GPT-5.6 (Sol, Terra, Luna) — disponible tanto en OpenAI Codex como en OpenAI Native.", - "highlight2": "Compatibilidad con Grok 4.5 — el nuevo modelo insignia de xAI, además de una corrección del reasoning effort que también beneficia a Grok 4 Mini.", - "highlight3": "Proveedor Kenari — un gateway de IA de primera línea compatible con OpenAI, facturado en rupias, que cubre Claude, GPT, DeepSeek, GLM, Kimi y más." + "highlight1": "Proveedores Moonshot y Kimi Code — conéctate a modelos de Moonshot con detección de modelos en tiempo real o inicia sesión en Kimi Code mediante su flujo de dispositivo OAuth.", + "highlight2": "Compatibilidad con los modelos más recientes — usa Claude Opus 5 con todos los proveedores, además de Kimi K3, Gemini 3.6 Flash y MiniMax-M3.", + "highlight3": "Flujos de trabajo más fiables — abandona subtareas interrumpidas de forma limpia e indexa archivos Dart y de texto sin formato en tu base de código." }, "cloudAgents": { "heading": "Novedades en la Nube:", diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index 820f003d40..91d28abb99 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Subtarea", "deleteWithSubtasks": "Esto también eliminará {{count}} subtarea(s). ¿Estás seguro?", "expandSubtasks": "Expandir subtareas", - "collapseSubtasks": "Contraer subtareas" + "collapseSubtasks": "Contraer subtareas", + "delegatedTag": "Esperando subtarea", + "interruptedTag": "Interrumpida" } diff --git a/webview-ui/src/i18n/locales/es/mcp.json b/webview-ui/src/i18n/locales/es/mcp.json index 768b4b6e59..e31f70c5ce 100644 --- a/webview-ui/src/i18n/locales/es/mcp.json +++ b/webview-ui/src/i18n/locales/es/mcp.json @@ -8,11 +8,6 @@ "title": "Activar servidores MCP", "description": "Actívalo para que Zoo pueda usar herramientas de servidores MCP conectados. Esto le da más capacidades a Zoo. Si no planeas usar estas herramientas extra, desactívalo para ayudar a reducir los costes de tokens API." }, - "enableServerCreation": { - "title": "Activar creación de servidores MCP", - "description": "Actívalo para que Zoo te ayude a crear <1>nuevos servidores MCP personalizados. <0>Más información sobre la creación de servidores", - "hint": "Consejo: Para reducir los costes de tokens API, desactiva esta opción cuando no le pidas a Zoo que cree un nuevo servidor MCP." - }, "editGlobalMCP": "Editar MCP global", "editProjectMCP": "Editar MCP del proyecto", "learnMoreEditingSettings": "Más información sobre cómo editar archivos de configuración MCP", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 7500e5bc77..eba338005f 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Clave API de Moonshot", "getMoonshotApiKey": "Obtener clave API de Moonshot", "moonshotBaseUrl": "Punto de entrada de Moonshot", + "kimiCode": { + "authMethod": "Método de autenticación", + "oauth": "Suscripción a Kimi Code (OAuth)", + "apiKey": "Clave de API de Kimi Code", + "apiKeyLabel": "Clave de API de Kimi Code", + "signIn": "Iniciar sesión en Kimi Code", + "signOut": "Cerrar sesión", + "authenticated": "Sesión iniciada en Kimi Code", + "deviceCodeHelp": "Introduce este código de dispositivo en la página de autorización de Kimi:", + "docs": "Documentación de Kimi Code" + }, "zaiApiKey": "Clave API de Z AI", "getZaiApiKey": "Obtener clave API de Z AI", "zaiEntrypoint": "Punto de entrada de Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Limitar el número máximo de tokens en la respuesta", "maxOutputTokensLabel": "Tokens máximos de salida", "maxTokensGenerateDescription": "Tokens máximos a generar en la respuesta", + "openAiCodexSpeed": { + "label": "Velocidad", + "tooltip": "El modo rápido usa el procesamiento prioritario de Codex para ofrecer aproximadamente 1,5 veces más velocidad y consume más cuota de suscripción.", + "standard": "Estándar", + "fast": "Rápido (1,5 veces más velocidad, mayor uso)" + }, "serviceTier": { "label": "Nivel de servicio", "tooltip": "Para un procesamiento más rápido de las solicitudes de API, prueba el nivel de servicio de procesamiento prioritario. Para precios más bajos con mayor latencia, prueba el nivel de procesamiento flexible.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 3238fcb101..080e3cb044 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condenser intelligemment le contexte", "openApiHistory": "Ouvrir l'historique de l'API", "openUiHistory": "Ouvrir l'historique de l'UI", - "backToParentTask": "Tâche parente" + "backToParentTask": "Tâche parente", + "waitingOnSubtask": "En attente de sous-tâche", + "goToSubtask": "Aller à la sous-tâche" }, "unpin": "Désépingler", "pin": "Épingler", @@ -192,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", @@ -206,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", @@ -341,9 +343,9 @@ "support": "Soutiens Zoo Code en nous donnant une étoile sur GitHub.", "release": { "heading": "Nouveautés :", - "highlight1": "Famille OpenAI GPT-5.6 (Sol, Terra, Luna) — disponible à la fois sur OpenAI Codex et OpenAI Native.", - "highlight2": "Prise en charge de Grok 4.5 — le nouveau modèle phare de xAI, avec une correction du reasoning effort qui profite aussi à Grok 4 Mini.", - "highlight3": "Provider Kenari — un gateway IA de premier ordre compatible OpenAI, facturé en roupies, couvrant Claude, GPT, DeepSeek, GLM, Kimi et plus." + "highlight1": "Providers Moonshot et Kimi Code — connecte-toi aux modèles Moonshot avec la découverte en temps réel, ou à Kimi Code via son flux d'appareil OAuth.", + "highlight2": "Prise en charge des derniers modèles — utilise Claude Opus 5 avec tous les providers, ainsi que Kimi K3, Gemini 3.6 Flash et MiniMax-M3.", + "highlight3": "Workflows plus fiables — abandonne proprement les sous-tâches interrompues et indexe les fichiers Dart et texte brut de ta base de code." }, "cloudAgents": { "heading": "Nouveautés dans le Cloud :", diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index d84cfcb190..443bb0eb3e 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Sous-tâche", "deleteWithSubtasks": "Cela supprimera aussi {{count}} sous-tâche(s). Êtes-vous sûr ?", "expandSubtasks": "Développer les sous-tâches", - "collapseSubtasks": "Réduire les sous-tâches" + "collapseSubtasks": "Réduire les sous-tâches", + "delegatedTag": "En attente de sous-tâche", + "interruptedTag": "Interrompue" } diff --git a/webview-ui/src/i18n/locales/fr/mcp.json b/webview-ui/src/i18n/locales/fr/mcp.json index 1698a7f241..f1f0032680 100644 --- a/webview-ui/src/i18n/locales/fr/mcp.json +++ b/webview-ui/src/i18n/locales/fr/mcp.json @@ -8,11 +8,6 @@ "title": "Activer les serveurs MCP", "description": "Active cette option pour que Zoo puisse utiliser des outils provenant de serveurs MCP connectés. Cela donne plus de capacités à Zoo. Si tu ne comptes pas utiliser ces outils supplémentaires, désactive-la pour réduire les coûts de tokens API." }, - "enableServerCreation": { - "title": "Activer la création de serveurs MCP", - "description": "Active cette option pour que Zoo t'aide à créer de <1>nouveaux serveurs MCP personnalisés. <0>En savoir plus sur la création de serveurs", - "hint": "Astuce : Pour réduire les coûts de tokens API, désactive cette option quand tu ne demandes pas à Zoo de créer un nouveau serveur MCP." - }, "editGlobalMCP": "Modifier le MCP global", "editProjectMCP": "Modifier le MCP du projet", "learnMoreEditingSettings": "En savoir plus sur la modification des fichiers de configuration MCP", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 9d5178f90f..d6e6e0e64e 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Clé API Moonshot", "getMoonshotApiKey": "Obtenir la clé API Moonshot", "moonshotBaseUrl": "Point d'entrée Moonshot", + "kimiCode": { + "authMethod": "Méthode d'authentification", + "oauth": "Abonnement Kimi Code (OAuth)", + "apiKey": "Clé API Kimi Code", + "apiKeyLabel": "Clé API Kimi Code", + "signIn": "Se connecter à Kimi Code", + "signOut": "Se déconnecter", + "authenticated": "Connecté à Kimi Code", + "deviceCodeHelp": "Saisissez ce code d'appareil sur la page d'autorisation Kimi :", + "docs": "Documentation Kimi Code" + }, "zaiApiKey": "Clé API Z AI", "getZaiApiKey": "Obtenir la clé API Z AI", "zaiEntrypoint": "Point d'entrée Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Limiter le nombre maximum de tokens dans la réponse", "maxOutputTokensLabel": "Tokens de sortie maximum", "maxTokensGenerateDescription": "Tokens maximum à générer dans la réponse", + "openAiCodexSpeed": { + "label": "Vitesse", + "tooltip": "Le mode rapide utilise le traitement prioritaire de Codex pour une vitesse environ 1,5 fois supérieure et consomme davantage de quota d'abonnement.", + "standard": "Standard", + "fast": "Rapide (vitesse 1,5 fois supérieure, utilisation accrue)" + }, "serviceTier": { "label": "Niveau de service", "tooltip": "Pour un traitement plus rapide des demandes d'API, essayez le niveau de service de traitement prioritaire. Pour des prix plus bas avec une latence plus élevée, essayez le niveau de traitement flexible.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index a1a0ed56a2..f2ac1cb20d 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -17,7 +17,9 @@ "condenseContext": "संदर्भ को बुद्धिमानी से संघनित करें", "openApiHistory": "API इतिहास खोलें", "openUiHistory": "UI इतिहास खोलें", - "backToParentTask": "मूल कार्य" + "backToParentTask": "मूल कार्य", + "waitingOnSubtask": "उपकार्य की प्रतीक्षा", + "goToSubtask": "उपकार्य पर जाएं" }, "unpin": "पिन करें", "pin": "अवपिन करें", @@ -185,7 +187,7 @@ "current": "वर्तमान" }, "instructions": { - "wantsToFetch": "Roo को वर्तमान कार्य में सहायता के लिए विस्तृत निर्देश प्राप्त करना है" + "wantsToFetch": "Zoo को वर्तमान कार्य में सहायता के लिए विस्तृत निर्देश प्राप्त करना है" }, "fileOperations": { "wantsToRead": "Zoo इस फ़ाइल को पढ़ना चाहता है", @@ -195,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 एक छवि बनाना चाहता है", @@ -244,7 +246,7 @@ "response": "प्रतिक्रिया", "arguments": "आर्ग्युमेंट्स", "text": { - "rooSaid": "रू ने कहा" + "rooSaid": "Zoo ने कहा" }, "feedback": { "youSaid": "आपने कहा" @@ -341,9 +343,9 @@ "support": "कृपया GitHub पर हमें स्टार देकर Zoo Code का समर्थन करें।", "release": { "heading": "नया क्या है:", - "highlight1": "OpenAI GPT-5.6 परिवार (Sol, Terra, Luna) — OpenAI Codex और OpenAI Native दोनों में उपलब्ध।", - "highlight2": "Grok 4.5 समर्थन — xAI का नया flagship model, साथ ही reasoning effort की एक फिक्स जिसका फायदा Grok 4 Mini को भी मिलता है।", - "highlight3": "Kenari provider — एक first-class OpenAI-compatible AI gateway जो Rupiah में बिल होता है और Claude, GPT, DeepSeek, GLM, Kimi और अधिक को cover करता है।" + "highlight1": "Moonshot और Kimi Code providers — live model discovery के साथ Moonshot models से कनेक्ट करें या OAuth device flow से Kimi Code में sign in करें।", + "highlight2": "नवीनतम model support — providers पर Claude Opus 5 के साथ Kimi K3, Gemini 3.6 Flash और MiniMax-M3 का उपयोग करें।", + "highlight3": "अधिक भरोसेमंद workflows — interrupted subtasks को साफ़ तौर पर छोड़ें और अपनी codebase में Dart व plain-text files को index करें।" }, "cloudAgents": { "heading": "क्लाउड में नया:", diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 0d4cb40dd9..3dd7cca9a9 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -47,5 +47,7 @@ "subtaskTag": "उप-कार्य", "deleteWithSubtasks": "यह {{count}} उप-कार्य(कों) को भी हटा देगा। क्या आप निश्चित हैं?", "expandSubtasks": "उप-कार्य विस्तारित करें", - "collapseSubtasks": "उप-कार्य संपीड़ित करें" + "collapseSubtasks": "उप-कार्य संपीड़ित करें", + "delegatedTag": "उपकार्य की प्रतीक्षा", + "interruptedTag": "बाधित" } diff --git a/webview-ui/src/i18n/locales/hi/mcp.json b/webview-ui/src/i18n/locales/hi/mcp.json index cc2290f9d7..7dd8b8a415 100644 --- a/webview-ui/src/i18n/locales/hi/mcp.json +++ b/webview-ui/src/i18n/locales/hi/mcp.json @@ -8,11 +8,6 @@ "title": "MCP सर्वर सक्षम करें", "description": "इसे ON करो ताकि Zoo जुड़े हुए MCP सर्वरों से टूल्स इस्तेमाल कर सके। इससे Zoo को और क्षमताएँ मिलती हैं। अगर तुम ये अतिरिक्त टूल्स इस्तेमाल नहीं करना चाहते, तो इसे OFF करो ताकि API टोकन लागत कम हो सके।" }, - "enableServerCreation": { - "title": "MCP सर्वर बनाना सक्षम करें", - "description": "इसे ON करो ताकि Zoo तुम्हारी मदद से <1>नए कस्टम MCP सर्वर बना सके। <0>सर्वर बनाना जानें", - "hint": "टिप: API टोकन लागत कम करने के लिए, जब Zoo से नया MCP सर्वर नहीं बनवा रहे हो तो इस सेटिंग को बंद कर दो।" - }, "editGlobalMCP": "ग्लोबल MCP एडिट करें", "editProjectMCP": "प्रोजेक्ट MCP एडिट करें", "learnMoreEditingSettings": "MCP सेटिंग्स फाइल एडिट करने के बारे में जानें", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2072485021..3ff02125c5 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": "स्वतः-अनुमोदन अक्षम - पहले विकल्प चुनें", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API कुंजी", "getMoonshotApiKey": "Moonshot API कुंजी प्राप्त करें", "moonshotBaseUrl": "Moonshot प्रवेश बिंदु", + "kimiCode": { + "authMethod": "प्रमाणीकरण विधि", + "oauth": "Kimi Code सदस्यता (OAuth)", + "apiKey": "Kimi Code API कुंजी", + "apiKeyLabel": "Kimi Code API कुंजी", + "signIn": "Kimi Code में साइन इन करें", + "signOut": "साइन आउट करें", + "authenticated": "Kimi Code में साइन इन किया गया", + "deviceCodeHelp": "Kimi प्राधिकरण पृष्ठ पर यह डिवाइस कोड दर्ज करें:", + "docs": "Kimi Code दस्तावेज़ीकरण" + }, "zaiApiKey": "Z AI API कुंजी", "getZaiApiKey": "Z AI API कुंजी प्राप्त करें", "zaiEntrypoint": "Z AI प्रवेश बिंदु", @@ -605,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "त्रुटि और पुनरावृत्ति सीमा", - "description": "'रू को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या। इस सुरक्षा तंत्र को अक्षम करने के लिए 0 पर सेट करें (यह कभी ट्रिगर नहीं होगा)।", + "description": "'Zoo को समस्या हो रही है' संवाद दिखाने से पहले लगातार त्रुटियों या दोहराए गए कार्यों की संख्या। इस सुरक्षा तंत्र को अक्षम करने के लिए 0 पर सेट करें (यह कभी ट्रिगर नहीं होगा)।", "unlimitedDescription": "असीमित पुनः प्रयास सक्षम (स्वतः आगे बढ़ें)। संवाद कभी नहीं दिखाई देगा।", "warning": "⚠️ 0 पर सेट करने से असीमित पुनः प्रयास की अनुमति मिलती है जिससे महत्वपूर्ण एपीआई उपयोग हो सकता है" }, @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "प्रतिक्रिया में टोकन की अधिकतम संख्या सीमित करें", "maxOutputTokensLabel": "अधिकतम आउटपुट टोकन", "maxTokensGenerateDescription": "प्रतिक्रिया में उत्पन्न करने के लिए अधिकतम टोकन", + "openAiCodexSpeed": { + "label": "गति", + "tooltip": "तेज़ मोड लगभग 1.5 गुना गति के लिए Codex की प्राथमिकता प्रोसेसिंग का उपयोग करता है और अधिक सदस्यता कोटा खर्च करता है।", + "standard": "मानक", + "fast": "तेज़ (1.5 गुना गति, अधिक उपयोग)" + }, "serviceTier": { "label": "सेवा स्तर", "tooltip": "API अनुरोधों के तेज़ प्रसंस्करण के लिए, प्राथमिकता प्रसंस्करण सेवा स्तर का प्रयास करें। उच्च विलंबता के साथ कम कीमतों के लिए, फ्लेक्स प्रसंस्करण स्तर का प्रयास करें।", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f0e73462e9..e95a517221 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -17,7 +17,9 @@ "delete": "Hapus Tugas (Shift + Klik untuk lewati konfirmasi)", "openApiHistory": "Buka Riwayat API", "openUiHistory": "Buka Riwayat UI", - "backToParentTask": "Tugas Induk" + "backToParentTask": "Tugas Induk", + "waitingOnSubtask": "Menunggu subtugas", + "goToSubtask": "Pergi ke subtugas" }, "history": { "title": "Riwayat" @@ -229,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", @@ -246,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" @@ -370,9 +372,9 @@ "support": "Dukung Zoo Code dengan memberi kami bintang di GitHub.", "release": { "heading": "Yang Baru:", - "highlight1": "Keluarga OpenAI GPT-5.6 (Sol, Terra, Luna) — tersedia di OpenAI Codex maupun OpenAI Native.", - "highlight2": "Dukungan Grok 4.5 — model andalan baru xAI, plus perbaikan reasoning effort yang juga menguntungkan Grok 4 Mini.", - "highlight3": "Provider Kenari — gateway AI kelas utama yang kompatibel dengan OpenAI, ditagih dalam Rupiah, mencakup Claude, GPT, DeepSeek, GLM, Kimi, dan lainnya." + "highlight1": "Provider Moonshot dan Kimi Code — hubungkan ke model Moonshot dengan penemuan model secara langsung, atau masuk ke Kimi Code melalui alur perangkat OAuth.", + "highlight2": "Dukungan model terbaru — gunakan Claude Opus 5 di berbagai provider, ditambah Kimi K3, Gemini 3.6 Flash, dan MiniMax-M3.", + "highlight3": "Workflow lebih andal — tinggalkan subtask yang terinterupsi dengan bersih, serta indeks file Dart dan teks biasa di codebase-mu." }, "cloudAgents": { "heading": "Baru di Cloud:", diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 7796061107..772ca25384 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -56,5 +56,7 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "Ini juga akan menghapus {{count}} subtask. Apakah Anda yakin?", "expandSubtasks": "Perluas subtask", - "collapseSubtasks": "Tutup subtask" + "collapseSubtasks": "Tutup subtask", + "delegatedTag": "Menunggu subtugas", + "interruptedTag": "Terganggu" } diff --git a/webview-ui/src/i18n/locales/id/mcp.json b/webview-ui/src/i18n/locales/id/mcp.json index 368da63b93..e22b10924a 100644 --- a/webview-ui/src/i18n/locales/id/mcp.json +++ b/webview-ui/src/i18n/locales/id/mcp.json @@ -8,11 +8,6 @@ "title": "Aktifkan Server MCP", "description": "Nyalakan ini untuk membiarkan Zoo menggunakan tools dari server MCP yang terhubung. Ini memberikan Zoo lebih banyak kemampuan. Jika Anda tidak berencana menggunakan tools tambahan ini, matikan untuk membantu mengurangi biaya token API." }, - "enableServerCreation": { - "title": "Aktifkan Pembuatan Server MCP", - "description": "Aktifkan ini agar Zoo membantu Anda membangun server MCP kustom <1>baru. <0>Pelajari tentang pembuatan server", - "hint": "Petunjuk: Untuk mengurangi biaya token API, nonaktifkan pengaturan ini ketika Anda tidak secara aktif meminta Zoo untuk membuat server MCP baru." - }, "editGlobalMCP": "Edit MCP Global", "editProjectMCP": "Edit MCP Proyek", "refreshMCP": "Refresh Server MCP", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 0b30cb84f4..6c4b91243f 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Kunci API Moonshot", "getMoonshotApiKey": "Dapatkan Kunci API Moonshot", "moonshotBaseUrl": "Titik Masuk Moonshot", + "kimiCode": { + "authMethod": "Metode autentikasi", + "oauth": "Langganan Kimi Code (OAuth)", + "apiKey": "Kunci API Kimi Code", + "apiKeyLabel": "Kunci API Kimi Code", + "signIn": "Masuk ke Kimi Code", + "signOut": "Keluar", + "authenticated": "Masuk ke Kimi Code", + "deviceCodeHelp": "Masukkan kode perangkat ini di halaman otorisasi Kimi:", + "docs": "Dokumentasi Kimi Code" + }, "zaiApiKey": "Kunci API Z AI", "getZaiApiKey": "Dapatkan Kunci API Z AI", "zaiEntrypoint": "Titik Masuk Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Batasi jumlah maksimum token dalam respons", "maxOutputTokensLabel": "Token output maksimum", "maxTokensGenerateDescription": "Token maksimum untuk dihasilkan dalam respons", + "openAiCodexSpeed": { + "label": "Kecepatan", + "tooltip": "Mode Cepat menggunakan pemrosesan prioritas Codex untuk kecepatan sekitar 1,5x dan memakai lebih banyak kuota langganan.", + "standard": "Standar", + "fast": "Cepat (kecepatan 1,5x, penggunaan meningkat)" + }, "serviceTier": { "label": "Tingkat layanan", "tooltip": "Untuk pemrosesan permintaan API yang lebih cepat, coba tingkat layanan pemrosesan prioritas. Untuk harga lebih rendah dengan latensi lebih tinggi, coba tingkat pemrosesan fleksibel.", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 2056e8d977..7085edee5b 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensa contesto in modo intelligente", "openApiHistory": "Apri cronologia API", "openUiHistory": "Apri cronologia UI", - "backToParentTask": "Attività principale" + "backToParentTask": "Attività principale", + "waitingOnSubtask": "In attesa di sottoattività", + "goToSubtask": "Vai alla sottoattività" }, "unpin": "Rilascia", "pin": "Fissa", @@ -139,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": { @@ -195,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", @@ -341,9 +343,9 @@ "support": "Sostieni Zoo Code lasciandoci una stella su GitHub.", "release": { "heading": "Novità:", - "highlight1": "Famiglia OpenAI GPT-5.6 (Sol, Terra, Luna) — disponibile sia su OpenAI Codex sia su OpenAI Native.", - "highlight2": "Supporto per Grok 4.5 — il nuovo modello di punta di xAI, più una correzione del reasoning effort che avvantaggia anche Grok 4 Mini.", - "highlight3": "Provider Kenari — un gateway AI di prima classe compatibile con OpenAI, fatturato in rupie, che copre Claude, GPT, DeepSeek, GLM, Kimi e altro." + "highlight1": "Provider Moonshot e Kimi Code — connettiti ai modelli Moonshot con il rilevamento dei modelli in tempo reale oppure accedi a Kimi Code tramite il flusso dispositivo OAuth.", + "highlight2": "Supporto per i modelli più recenti — usa Claude Opus 5 con tutti i provider, oltre a Kimi K3, Gemini 3.6 Flash e MiniMax-M3.", + "highlight3": "Workflow più affidabili — abbandona in modo pulito le sottoattività interrotte e indicizza i file Dart e di testo semplice nella tua codebase." }, "cloudAgents": { "heading": "Novità nel Cloud:", diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index aa728ef8f6..4097d43ce2 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Sottoattività", "deleteWithSubtasks": "Questo eliminerà anche {{count}} sottoattività. Sei sicuro?", "expandSubtasks": "Espandi sottoattività", - "collapseSubtasks": "Comprimi sottoattività" + "collapseSubtasks": "Comprimi sottoattività", + "delegatedTag": "In attesa di sottoattività", + "interruptedTag": "Interrotta" } diff --git a/webview-ui/src/i18n/locales/it/mcp.json b/webview-ui/src/i18n/locales/it/mcp.json index e5f4aff9eb..dc8e6caa6c 100644 --- a/webview-ui/src/i18n/locales/it/mcp.json +++ b/webview-ui/src/i18n/locales/it/mcp.json @@ -8,11 +8,6 @@ "title": "Abilita server MCP", "description": "Attiva questa opzione per permettere a Zoo di usare strumenti dai server MCP collegati. Questo dà a Zoo più capacità. Se non vuoi usare questi strumenti extra, disattiva per ridurre i costi dei token API." }, - "enableServerCreation": { - "title": "Abilita creazione server MCP", - "description": "Abilita questa opzione per farti aiutare da Zoo a creare <1>nuovi server MCP personalizzati. <0>Scopri di più sulla creazione di server", - "hint": "Suggerimento: Per ridurre i costi dei token API, disattiva questa impostazione quando non chiedi a Zoo di creare un nuovo server MCP." - }, "editGlobalMCP": "Modifica MCP globale", "editProjectMCP": "Modifica MCP del progetto", "learnMoreEditingSettings": "Scopri di più sulla modifica dei file di configurazione MCP", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 125e6a725f..8f7fd7e917 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Chiave API Moonshot", "getMoonshotApiKey": "Ottieni chiave API Moonshot", "moonshotBaseUrl": "Punto di ingresso Moonshot", + "kimiCode": { + "authMethod": "Metodo di autenticazione", + "oauth": "Abbonamento Kimi Code (OAuth)", + "apiKey": "Chiave API Kimi Code", + "apiKeyLabel": "Chiave API Kimi Code", + "signIn": "Accedi a Kimi Code", + "signOut": "Esci", + "authenticated": "Accesso a Kimi Code effettuato", + "deviceCodeHelp": "Inserisci questo codice dispositivo nella pagina di autorizzazione Kimi:", + "docs": "Documentazione Kimi Code" + }, "zaiApiKey": "Chiave API Z AI", "getZaiApiKey": "Ottieni chiave API Z AI", "zaiEntrypoint": "Punto di ingresso Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Limita il numero massimo di token nella risposta", "maxOutputTokensLabel": "Token di output massimi", "maxTokensGenerateDescription": "Token massimi da generare nella risposta", + "openAiCodexSpeed": { + "label": "Velocità", + "tooltip": "La modalità veloce usa l'elaborazione prioritaria di Codex per una velocità circa 1,5 volte superiore e consuma più quota dell'abbonamento.", + "standard": "Standard", + "fast": "Veloce (velocità 1,5 volte superiore, utilizzo maggiore)" + }, "serviceTier": { "label": "Livello di servizio", "tooltip": "Per un'elaborazione più rapida delle richieste API, prova il livello di servizio di elaborazione prioritaria. Per prezzi più bassi con una latenza maggiore, prova il livello di elaborazione flessibile.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 88aa788626..8abbe5792c 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -17,7 +17,9 @@ "condenseContext": "コンテキストをインテリジェントに圧縮", "openApiHistory": "API履歴を開く", "openUiHistory": "UI履歴を開く", - "backToParentTask": "親タスク" + "backToParentTask": "親タスク", + "waitingOnSubtask": "サブタスク待ち", + "goToSubtask": "サブタスクへ" }, "unpin": "ピン留めを解除", "pin": "ピン留め", @@ -128,7 +130,7 @@ "title": "モード", "marketplace": "モードマーケットプレイス", "settings": "モード設定", - "description": "Rooの動作をカスタマイズする専門的なペルソナ。", + "description": "Zooの動作をカスタマイズする専門的なペルソナ。", "searchPlaceholder": "モードを検索...", "noResults": "結果が見つかりません" }, @@ -142,7 +144,7 @@ "diffError": { "title": "編集に失敗しました" }, - "troubleMessage": "Rooに問題が発生しています...", + "troubleMessage": "Zooに問題が発生しています...", "apiRequest": { "title": "APIリクエスト", "failed": "APIリクエスト失敗", @@ -185,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": "コマンド出力", @@ -244,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": "サブタスク結果", @@ -270,7 +272,7 @@ "goToSubtask": "タスクを表示" }, "questions": { - "hasQuestion": "Rooは質問があります" + "hasQuestion": "Zooは質問があります" }, "taskCompleted": "タスク完了", "modelResponseIncomplete": "モデルの応答が不完全", @@ -286,7 +288,7 @@ "copyToClipboard": "クリップボードにコピー", "copied": "コピーしました!", "diagnostics": "詳細なエラー情報を取得", - "proxyProvider": "プロキシベースのプロバイダーを使用しているようです。ログを確認し、Rooのリクエストを書き換えていないことを確認してください。" + "proxyProvider": "プロキシベースのプロバイダーを使用しているようです。ログを確認し、Zooのリクエストを書き換えていないことを確認してください。" }, "powershell": { "issues": "Windows PowerShellに問題があるようです。こちらを参照してください" @@ -329,8 +331,8 @@ } }, "skill": { - "wantsToLoad": "Rooはスキルを読み込もうとしています", - "didLoad": "Rooはスキルを読み込みました" + "wantsToLoad": "Zooはスキルを読み込もうとしています", + "didLoad": "Zooはスキルを読み込みました" }, "followUpSuggest": { "copyToInput": "入力欄にコピー(またはShift + クリック)", @@ -341,9 +343,9 @@ "support": "GitHubでスターを付けて Zoo Code を応援してください。", "release": { "heading": "新機能:", - "highlight1": "OpenAI GPT-5.6 ファミリー(Sol、Terra、Luna)— OpenAI Codex と OpenAI Native の両方で利用可能。", - "highlight2": "Grok 4.5 サポート — xAI の新しいフラッグシップモデル。あわせて reasoning effort の修正も行われ、Grok 4 Mini にも恩恵があります。", - "highlight3": "Kenari プロバイダー — ルピー建てで請求される、Claude・GPT・DeepSeek・GLM・Kimi などに対応した OpenAI 互換のファーストクラス AI ゲートウェイ。" + "highlight1": "Moonshot と Kimi Code プロバイダー — ライブモデル検出で Moonshot モデルに接続するか、OAuth デバイスフローで Kimi Code にサインインできます。", + "highlight2": "最新モデルのサポート — 各プロバイダーで Claude Opus 5 を利用できるほか、Kimi K3、Gemini 3.6 Flash、MiniMax-M3 にも対応しました。", + "highlight3": "より信頼性の高いワークフロー — 中断されたサブタスクを安全に破棄し、コードベース内の Dart ファイルとプレーンテキストファイルをインデックスできます。" }, "cloudAgents": { "heading": "クラウドの新機能:", @@ -373,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}} (クリックしてファイルを開く)" @@ -466,8 +468,8 @@ "clickToEdit": "クリックしてメッセージを編集" }, "slashCommand": { - "wantsToRun": "Rooはスラッシュコマンドを実行したい", - "didRun": "Rooはスラッシュコマンドを実行しました" + "wantsToRun": "Zooはスラッシュコマンドを実行したい", + "didRun": "Zooはスラッシュコマンドを実行しました" }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", @@ -486,7 +488,7 @@ "openMcpSettings": "MCP 設定を開く" }, "readCommandOutput": { - "title": "Rooがコマンド出力を読み込みました" + "title": "Zooがコマンド出力を読み込みました" }, "retiredProvider": { "title": "プロバイダーのサポート終了", diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index b73baa3a76..be4897ba34 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -47,5 +47,7 @@ "subtaskTag": "サブタスク", "deleteWithSubtasks": "これにより {{count}} サブタスクも削除されます。よろしいですか?", "expandSubtasks": "サブタスクを展開", - "collapseSubtasks": "サブタスクを折りたたむ" + "collapseSubtasks": "サブタスクを折りたたむ", + "delegatedTag": "サブタスク待ち", + "interruptedTag": "中断" } diff --git a/webview-ui/src/i18n/locales/ja/mcp.json b/webview-ui/src/i18n/locales/ja/mcp.json index d415e30f08..66151f3d60 100644 --- a/webview-ui/src/i18n/locales/ja/mcp.json +++ b/webview-ui/src/i18n/locales/ja/mcp.json @@ -8,11 +8,6 @@ "title": "MCPサーバーを有効化", "description": "これをONにすると、Rooが接続されたMCPサーバーのツールを使えるようになるよ。Rooの機能が増える!追加ツールを使わないなら、APIトークンのコストを抑えるためにOFFにしてね。" }, - "enableServerCreation": { - "title": "MCPサーバー作成を有効化", - "description": "これをONにすると、Rooが<1>新しいカスタムMCPサーバーを作るのを手伝ってくれるよ。<0>サーバー作成について詳しく", - "hint": "ヒント: APIトークンのコストを抑えたいときは、Rooに新しいMCPサーバーを作らせないときにこの設定をOFFにしてね。" - }, "editGlobalMCP": "グローバルMCPを編集", "editProjectMCP": "プロジェクトMCPを編集", "learnMoreEditingSettings": "MCP設定ファイルの編集方法を詳しく見る", 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 dd07efdd9a..ab692a49f8 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": { @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot APIキー", "getMoonshotApiKey": "Moonshot APIキーを取得", "moonshotBaseUrl": "Moonshot エントリーポイント", + "kimiCode": { + "authMethod": "認証方法", + "oauth": "Kimi Code サブスクリプション (OAuth)", + "apiKey": "Kimi Code API キー", + "apiKeyLabel": "Kimi Code API キー", + "signIn": "Kimi Code にサインイン", + "signOut": "サインアウト", + "authenticated": "Kimi Code にサインイン済み", + "deviceCodeHelp": "Kimi 認証ページでこのデバイスコードを入力してください:", + "docs": "Kimi Code ドキュメント" + }, "zaiApiKey": "Z AI APIキー", "getZaiApiKey": "Z AI APIキーを取得", "zaiEntrypoint": "Z AI エントリーポイント", @@ -605,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "エラーと繰り返しの制限", - "description": "「Rooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数。この安全機構を無効にするには0に設定します(トリガーされません)。", + "description": "「Zooが問題を抱えています」ダイアログを表示するまでの連続エラーまたは繰り返しアクションの数。この安全機構を無効にするには0に設定します(トリガーされません)。", "unlimitedDescription": "無制限のリトライが有効です(自動進行)。ダイアログは表示されません。", "warning": "⚠️ 0に設定すると無制限のリトライが可能になり、API使用量が大幅に増加する可能性があります" }, @@ -649,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": "速度" } }, @@ -668,7 +679,7 @@ "description": "AIのコンテキストウィンドウに含まれる情報を制御し、token使用量とレスポンスの品質に影響します", "autoCondenseContextPercent": { "label": "インテリジェントなコンテキスト圧縮をトリガーするしきい値", - "description": "コンテキストウィンドウがこのしきい値に達すると、Rooは自動的に圧縮します。" + "description": "コンテキストウィンドウがこのしきい値に達すると、Zooは自動的に圧縮します。" }, "condensingApiConfiguration": { "label": "コンテキスト圧縮用のAPI設定", @@ -684,7 +695,7 @@ }, "autoCondenseContext": { "name": "インテリジェントなコンテキスト圧縮を自動的にトリガーする", - "description": "有効にすると、Rooは閾値に達したときに自動的にコンテキストを圧縮します。無効にすると、手動でコンテキスト圧縮をトリガーできます。" + "description": "有効にすると、Zooは閾値に達したときに自動的にコンテキストを圧縮します。無効にすると、手動でコンテキスト圧縮をトリガーできます。" }, "openTabs": { "label": "オープンタブコンテキスト制限", @@ -700,7 +711,7 @@ }, "maxReadFile": { "label": "ファイル読み込み自動切り詰めしきい値", - "description": "モデルが開始/終了の値を指定しない場合、Rooはこの行数を読み込みます。この数がファイルの総行数より少ない場合、Rooはコード定義の行番号インデックスを生成します。特殊なケース:-1はRooにファイル全体を読み込むよう指示し(インデックス作成なし)、0は行を読み込まず最小限のコンテキストのために行インデックスのみを提供するよう指示します。低い値は初期コンテキスト使用量を最小限に抑え、後続の正確な行範囲の読み込みを可能にします。明示的な開始/終了の要求はこの設定による制限を受けません。", + "description": "モデルが開始/終了の値を指定しない場合、Zooはこの行数を読み込みます。この数がファイルの総行数より少ない場合、Zooはコード定義の行番号インデックスを生成します。特殊なケース:-1はZooにファイル全体を読み込むよう指示し(インデックス作成なし)、0は行を読み込まず最小限のコンテキストのために行インデックスのみを提供するよう指示します。低い値は初期コンテキスト使用量を最小限に抑え、後続の正確な行範囲の読み込みを可能にします。明示的な開始/終了の要求はこの設定による制限を受けません。", "lines": "行", "always_full_read": "常にファイル全体を読み込む" }, @@ -777,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)", @@ -842,7 +853,7 @@ "advanced": { "diff": { "label": "diff経由の編集を有効化", - "description": "有効にすると、Rooはファイルをより迅速に編集でき、切り詰められた全ファイル書き込みを自動的に拒否します。", + "description": "有効にすると、Zooはファイルをより迅速に編集でき、切り詰められた全ファイル書き込みを自動的に拒否します。", "strategy": { "label": "Diff戦略", "options": { @@ -859,7 +870,7 @@ }, "todoList": { "label": "ToDoリストツールを有効にする", - "description": "有効にすると、Rooはタスクの進捗を追跡するためのToDoリストを作成・管理できます。これにより、複雑なタスクを管理しやすいステップに整理できます。" + "description": "有効にすると、Zooはタスクの進捗を追跡するためのToDoリストを作成・管理できます。これにより、複雑なタスクを管理しやすいステップに整理できます。" } }, "experimental": { @@ -869,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を有効にする", @@ -885,7 +896,7 @@ }, "PREVENT_FOCUS_DISRUPTION": { "name": "バックグラウンド編集", - "description": "有効にすると、エディターのフォーカス中断を防ぎます。ファイル編集は差分ビューを開いたりフォーカスを奪ったりすることなく、バックグラウンドで行われます。Rooが変更を行っている間も中断されることなく作業を続けることができます。ファイルは診断をキャプチャするためにフォーカスなしで開くか、完全に閉じたままにできます。" + "description": "有効にすると、エディターのフォーカス中断を防ぎます。ファイル編集は差分ビューを開いたりフォーカスを奪ったりすることなく、バックグラウンドで行われます。Zooが変更を行っている間も中断されることなく作業を続けることができます。ファイルは診断をキャプチャするためにフォーカスなしで開くか、完全に閉じたままにできます。" }, "ASSISTANT_MESSAGE_PARSER": { "name": "新しいメッセージパーサーを使う", @@ -899,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キーを取得する場所", @@ -910,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": "更新", @@ -926,7 +937,7 @@ }, "promptCaching": { "label": "プロンプトキャッシュを無効化", - "description": "チェックすると、Rooはこのモデルに対してプロンプトキャッシュを使用しません。" + "description": "チェックすると、Zooはこのモデルに対してプロンプトキャッシュを使用しません。" }, "temperature": { "useCustom": "カスタム温度を使用", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "レスポンスの最大トークン数を制限する", "maxOutputTokensLabel": "最大出力トークン", "maxTokensGenerateDescription": "レスポンスで生成する最大トークン数", + "openAiCodexSpeed": { + "label": "速度", + "tooltip": "高速モードは Codex の優先処理を使用して約1.5倍の速度を実現し、サブスクリプションの利用枠をより多く消費します。", + "standard": "標準", + "fast": "高速(約1.5倍、使用量増加)" + }, "serviceTier": { "label": "サービスティア", "tooltip": "APIリクエストをより速く処理するには、優先処理サービスティアをお試しください。低価格でレイテンシが高い場合は、フレックス処理ティアをお試しください。", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index d826bf7ca8..ac7b6c4c05 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -17,7 +17,9 @@ "condenseContext": "컨텍스트 지능적으로 압축", "openApiHistory": "API 기록 열기", "openUiHistory": "UI 기록 열기", - "backToParentTask": "상위 작업" + "backToParentTask": "상위 작업", + "waitingOnSubtask": "하위 작업 대기 중", + "goToSubtask": "하위 작업으로 이동" }, "unpin": "고정 해제하기", "pin": "고정하기", @@ -128,7 +130,7 @@ "title": "모드", "marketplace": "모드 마켓플레이스", "settings": "모드 설정", - "description": "Roo의 행동을 맞춤화하는 전문화된 페르소나.", + "description": "Zoo의 행동을 맞춤화하는 전문화된 페르소나.", "searchPlaceholder": "모드 검색...", "noResults": "결과를 찾을 수 없습니다" }, @@ -142,7 +144,7 @@ "diffError": { "title": "편집 실패" }, - "troubleMessage": "Roo에 문제가 발생했습니다...", + "troubleMessage": "Zoo에 문제가 발생했습니다...", "apiRequest": { "title": "API 요청", "failed": "API 요청 실패", @@ -185,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": { @@ -250,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": "하위 작업 결과", @@ -270,7 +272,7 @@ "goToSubtask": "작업 보기" }, "questions": { - "hasQuestion": "Roo에게 질문이 있습니다" + "hasQuestion": "Zoo에게 질문이 있습니다" }, "taskCompleted": "작업 완료", "modelResponseIncomplete": "모델 응답 불완전", @@ -286,7 +288,7 @@ "copyToClipboard": "클립보드에 복사", "copied": "복사됨!", "diagnostics": "상세한 오류 정보 가져오기", - "proxyProvider": "프록시 기반 제공자를 사용하는 것 같습니다. 로그를 확인하고 Roo의 요청을 다시 작성하지 않는지 확인하세요." + "proxyProvider": "프록시 기반 제공자를 사용하는 것 같습니다. 로그를 확인하고 Zoo의 요청을 다시 작성하지 않는지 확인하세요." }, "powershell": { "issues": "Windows PowerShell에 문제가 있는 것 같습니다. 다음을 참조하세요" @@ -329,8 +331,8 @@ } }, "skill": { - "wantsToLoad": "Roo가 스킬을 로드하려고 합니다", - "didLoad": "Roo가 스킬을 로드했습니다" + "wantsToLoad": "Zoo가 스킬을 로드하려고 합니다", + "didLoad": "Zoo가 스킬을 로드했습니다" }, "followUpSuggest": { "copyToInput": "입력창에 복사 (또는 Shift + 클릭)", @@ -341,9 +343,9 @@ "support": "GitHub에서 별표를 눌러 Zoo Code를 응원해 주세요.", "release": { "heading": "새로운 기능:", - "highlight1": "OpenAI GPT-5.6 패밀리(Sol, Terra, Luna) — OpenAI Codex와 OpenAI Native 양쪽에서 사용 가능합니다.", - "highlight2": "Grok 4.5 지원 — xAI의 새로운 플래그십 모델이며, Grok 4 Mini에도 도움이 되는 reasoning effort 수정도 포함됩니다.", - "highlight3": "Kenari 프로바이더 — 루피아로 청구되며 Claude, GPT, DeepSeek, GLM, Kimi 등을 지원하는 OpenAI 호환 퍼스트클래스 AI 게이트웨이." + "highlight1": "Moonshot 및 Kimi Code 프로바이더 — 실시간 모델 검색으로 Moonshot 모델에 연결하거나 OAuth 기기 흐름으로 Kimi Code에 로그인하세요.", + "highlight2": "최신 모델 지원 — 여러 프로바이더에서 Claude Opus 5를 사용하고 Kimi K3, Gemini 3.6 Flash, MiniMax-M3도 이용하세요.", + "highlight3": "더 안정적인 워크플로우 — 중단된 하위 작업을 깔끔하게 포기하고 코드베이스의 Dart 및 일반 텍스트 파일을 인덱싱하세요." }, "cloudAgents": { "heading": "클라우드의 새로운 기능:", @@ -373,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}} (클릭하여 파일 열기)" @@ -466,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/history.json b/webview-ui/src/i18n/locales/ko/history.json index 0363feaaff..8a13ff12cd 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -47,5 +47,7 @@ "subtaskTag": "부분작업", "deleteWithSubtasks": "이는 {{count}} 부분작업도 삭제합니다. 확실하십니까?", "expandSubtasks": "부분작업 확장", - "collapseSubtasks": "부분작업 축소" + "collapseSubtasks": "부분작업 축소", + "delegatedTag": "하위 작업 대기 중", + "interruptedTag": "중단됨" } diff --git a/webview-ui/src/i18n/locales/ko/mcp.json b/webview-ui/src/i18n/locales/ko/mcp.json index b0ecb215e1..2327ef0ca5 100644 --- a/webview-ui/src/i18n/locales/ko/mcp.json +++ b/webview-ui/src/i18n/locales/ko/mcp.json @@ -8,11 +8,6 @@ "title": "MCP 서버 활성화", "description": "이걸 켜면 Roo가 연결된 MCP 서버의 도구를 쓸 수 있어. Roo의 능력이 더 늘어나! 추가 도구를 쓸 생각이 없다면, API 토큰 비용을 줄이기 위해 꺼 두는 게 좋아." }, - "enableServerCreation": { - "title": "MCP 서버 생성 활성화", - "description": "이걸 켜면 Roo가 <1>새로운 맞춤형 MCP 서버를 만드는 걸 도와줄 수 있어. <0>서버 생성에 대해 알아보기", - "hint": "팁: API 토큰 비용을 줄이고 싶으면, Roo에게 새 MCP 서버를 만들라고 하지 않을 때 이 설정을 꺼 둬." - }, "editGlobalMCP": "글로벌 MCP 편집", "editProjectMCP": "프로젝트 MCP 편집", "learnMoreEditingSettings": "MCP 설정 파일 편집 방법 더 알아보기", 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 36a4ee14e0..4e44f8170d 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": { @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API 키", "getMoonshotApiKey": "Moonshot API 키 받기", "moonshotBaseUrl": "Moonshot 엔트리포인트", + "kimiCode": { + "authMethod": "인증 방법", + "oauth": "Kimi Code 구독 (OAuth)", + "apiKey": "Kimi Code API 키", + "apiKeyLabel": "Kimi Code API 키", + "signIn": "Kimi Code에 로그인", + "signOut": "로그아웃", + "authenticated": "Kimi Code에 로그인됨", + "deviceCodeHelp": "Kimi 인증 페이지에서 이 디바이스 코드를 입력하세요:", + "docs": "Kimi Code 문서" + }, "zaiApiKey": "Z AI API 키", "getZaiApiKey": "Z AI API 키 받기", "zaiEntrypoint": "Z AI 엔트리포인트", @@ -605,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "오류 및 반복 제한", - "description": "'Roo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수. 이 안전 메커니즘을 비활성화하려면 0으로 설정하세요(절대 트리거되지 않음).", + "description": "'Zoo에 문제가 발생했습니다' 대화 상자를 표시하기 전의 연속 오류 또는 반복 작업 수. 이 안전 메커니즘을 비활성화하려면 0으로 설정하세요(절대 트리거되지 않음).", "unlimitedDescription": "무제한 재시도 활성화 (자동 진행). 대화 상자가 나타나지 않습니다.", "warning": "⚠️ 0으로 설정하면 무제한 재시도가 허용되어 상당한 API 사용량이 발생할 수 있습니다" }, @@ -649,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": "속도" } }, @@ -668,7 +679,7 @@ "description": "AI의 컨텍스트 창에 포함되는 정보를 제어하여 token 사용량과 응답 품질에 영향을 미칩니다", "autoCondenseContextPercent": { "label": "지능적 컨텍스트 압축을 트리거하는 임계값", - "description": "컨텍스트 창이 이 임계값에 도달하면 Roo가 자동으로 압축합니다." + "description": "컨텍스트 창이 이 임계값에 도달하면 Zoo가 자동으로 압축합니다." }, "condensingApiConfiguration": { "label": "컨텍스트 압축을 위한 API 설정", @@ -684,7 +695,7 @@ }, "autoCondenseContext": { "name": "지능적 컨텍스트 압축 자동 트리거", - "description": "활성화되면 Roo는 임계값에 도달했을 때 자동으로 컨텍스트를 압축합니다. 비활성화되면 여전히 수동으로 컨텍스트 압축을 트리거할 수 있습니다." + "description": "활성화되면 Zoo는 임계값에 도달했을 때 자동으로 컨텍스트를 압축합니다. 비활성화되면 여전히 수동으로 컨텍스트 압축을 트리거할 수 있습니다." }, "openTabs": { "label": "열린 탭 컨텍스트 제한", @@ -700,7 +711,7 @@ }, "maxReadFile": { "label": "파일 읽기 자동 축소 임계값", - "description": "모델이 시작/끝 값을 지정하지 않을 때 Roo가 읽는 줄 수입니다. 이 수가 파일의 총 줄 수보다 적으면 Roo는 코드 정의의 줄 번호 인덱스를 생성합니다. 특수한 경우: -1은 Roo에게 전체 파일을 읽도록 지시하고(인덱싱 없이), 0은 줄을 읽지 않고 최소한의 컨텍스트를 위해 줄 인덱스만 제공하도록 지시합니다. 낮은 값은 초기 컨텍스트 사용을 최소화하고, 이후 정확한 줄 범위 읽기를 가능하게 합니다. 명시적 시작/끝 요청은 이 설정의 제한을 받지 않습니다.", + "description": "모델이 시작/끝 값을 지정하지 않을 때 Zoo가 읽는 줄 수입니다. 이 수가 파일의 총 줄 수보다 적으면 Zoo는 코드 정의의 줄 번호 인덱스를 생성합니다. 특수한 경우: -1은 Zoo에게 전체 파일을 읽도록 지시하고(인덱싱 없이), 0은 줄을 읽지 않고 최소한의 컨텍스트를 위해 줄 인덱스만 제공하도록 지시합니다. 낮은 값은 초기 컨텍스트 사용을 최소화하고, 이후 정확한 줄 범위 읽기를 가능하게 합니다. 명시적 시작/끝 요청은 이 설정의 제한을 받지 않습니다.", "lines": "줄", "always_full_read": "항상 전체 파일 읽기" }, @@ -777,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)", @@ -798,7 +809,7 @@ }, "shellIntegrationDisabled": { "label": "인라인 터미널 사용(권장)", - "description": "더 빠르고 안정적인 실행을 위해 셸 프로필/통합��� 우회하려면 인라인 터미널(채팅)에�� 명령을 실행하십시오. 비활성화하면 Roo는 셸 프로필, 프롬프트 및 플러그인과 함께 VS Code 터미널을 사용합니다. <0>자세히 알아보기" + "description": "더 빠르고 안정적인 실행을 위해 셸 프로필/통합을 우회하려면 인라인 터미널(채팅)에서 명령을 실행하십시오. 비활성화하면 Zoo는 셸 프로필, 프롬프트 및 플러그인과 함께 VS Code 터미널을 사용합니다. <0>자세히 알아보기" }, "commandDelay": { "label": "터미널 명령 지연", @@ -826,7 +837,7 @@ }, "inheritEnv": { "label": "환경 변수 상속", - "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" + "description": "부모 VS Code 프로세스에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" }, "profile": { "label": "Zoo Code 터미널 프로필", @@ -842,7 +853,7 @@ "advanced": { "diff": { "label": "diff를 통한 편집 활성화", - "description": "활성화되면 Roo는 파일을 더 빠르게 편집할 수 있으며 잘린 전체 파일 쓰기를 자동으로 거부합니다", + "description": "활성화되면 Zoo는 파일을 더 빠르게 편집할 수 있으며 잘린 전체 파일 쓰기를 자동으로 거부합니다", "strategy": { "label": "Diff 전략", "options": { @@ -859,7 +870,7 @@ }, "todoList": { "label": "할 일 목록 도구 활성화", - "description": "활성화하면 Roo가 작업 진행 상황을 추적하기 위한 할 일 목록을 만들고 관리할 수 있습니다. 이는 복잡한 작업을 관리 가능한 단계로 구성하는 데 도움이 됩니다." + "description": "활성화하면 Zoo가 작업 진행 상황을 추적하기 위한 할 일 목록을 만들고 관리할 수 있습니다. 이는 복잡한 작업을 관리 가능한 단계로 구성하는 데 도움이 됩니다." } }, "experimental": { @@ -869,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 활성화", @@ -885,7 +896,7 @@ }, "PREVENT_FOCUS_DISRUPTION": { "name": "백그라운드 편집", - "description": "활성화하면 편집기 포커스 방해를 방지합니다. 파일 편집이 diff 뷰를 열거나 포커스를 빼앗지 않고 백그라운드에서 수행됩니다. Roo가 변경사항을 적용하는 동안 방해받지 않고 계속 작업할 수 있습니다. 파일은 진단을 캡처하기 위해 포커스 없이 열거나 완전히 닫힌 상태로 유지할 수 있습니다." + "description": "활성화하면 편집기 포커스 방해를 방지합니다. 파일 편집이 diff 뷰를 열거나 포커스를 빼앗지 않고 백그라운드에서 수행됩니다. Zoo가 변경사항을 적용하는 동안 방해받지 않고 계속 작업할 수 있습니다. 파일은 진단을 캡처하기 위해 포커스 없이 열거나 완전히 닫힌 상태로 유지할 수 있습니다." }, "ASSISTANT_MESSAGE_PARSER": { "name": "새 메시지 파서 사용", @@ -899,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 키를 받을 곳", @@ -910,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": "새로고침", @@ -926,7 +937,7 @@ }, "promptCaching": { "label": "프롬프트 캐싱 비활성화", - "description": "체크하면 Roo가 이 모델에 대해 프롬프트 캐싱을 사용하지 않습니다." + "description": "체크하면 Zoo가 이 모델에 대해 프롬프트 캐싱을 사용하지 않습니다." }, "temperature": { "useCustom": "사용자 정의 온도 사용", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "응답에서 최대 토큰 수 제한", "maxOutputTokensLabel": "최대 출력 토큰", "maxTokensGenerateDescription": "응답에서 생성할 최대 토큰 수", + "openAiCodexSpeed": { + "label": "속도", + "tooltip": "빠른 모드는 Codex 우선 처리를 사용하여 약 1.5배 빠른 속도를 제공하며 구독 할당량을 더 많이 사용합니다.", + "standard": "표준", + "fast": "빠름(1.5배 속도, 사용량 증가)" + }, "serviceTier": { "label": "서비스 등급", "tooltip": "API 요청을 더 빠르게 처리하려면 우선 처리 서비스 등급을 사용해 보세요. 더 낮은 가격에 더 높은 지연 시간을 원하시면 플렉스 처리 등급을 사용해 보세요.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index ad489050e7..aa7608b1f3 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Context intelligent samenvatten", "openApiHistory": "API-geschiedenis openen", "openUiHistory": "UI-geschiedenis openen", - "backToParentTask": "Bovenliggende taak" + "backToParentTask": "Bovenliggende taak", + "waitingOnSubtask": "Wacht op subtaak", + "goToSubtask": "Ga naar subtaak" }, "unpin": "Losmaken", "pin": "Vastmaken", @@ -180,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", @@ -190,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", @@ -314,9 +316,9 @@ "support": "Steun Zoo Code door ons een ster te geven op GitHub.", "release": { "heading": "Wat is nieuw:", - "highlight1": "OpenAI GPT-5.6-familie (Sol, Terra, Luna) — beschikbaar via zowel OpenAI Codex als OpenAI Native.", - "highlight2": "Ondersteuning voor Grok 4.5 — xAI's nieuwe vlaggenschipmodel, plus een fix voor reasoning effort waar ook Grok 4 Mini baat bij heeft.", - "highlight3": "Kenari-provider — een eersteklas OpenAI-compatibele AI-gateway die in Rupiah wordt gefactureerd en Claude, GPT, DeepSeek, GLM, Kimi en meer ondersteunt." + "highlight1": "Moonshot- en Kimi Code-providers — maak verbinding met Moonshot-modellen via live modeldetectie of meld je aan bij Kimi Code via de OAuth-apparaatstroom.", + "highlight2": "Ondersteuning voor de nieuwste modellen — gebruik Claude Opus 5 bij verschillende providers, plus Kimi K3, Gemini 3.6 Flash en MiniMax-M3.", + "highlight3": "Betrouwbaardere workflows — breek onderbroken subtaken netjes af en indexeer Dart- en plattetekstbestanden in je codebase." }, "cloudAgents": { "heading": "Nieuw in de Cloud:", diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index 012059ed04..db1515bfe5 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtaak", "deleteWithSubtasks": "Dit zal ook {{count}} subtaak(en) verwijderen. Weet je het zeker?", "expandSubtasks": "Subtaken uitvouwen", - "collapseSubtasks": "Subtaken samenvouwen" + "collapseSubtasks": "Subtaken samenvouwen", + "delegatedTag": "Wacht op subtaak", + "interruptedTag": "Onderbroken" } diff --git a/webview-ui/src/i18n/locales/nl/mcp.json b/webview-ui/src/i18n/locales/nl/mcp.json index 8a4b2089c0..035f4fa869 100644 --- a/webview-ui/src/i18n/locales/nl/mcp.json +++ b/webview-ui/src/i18n/locales/nl/mcp.json @@ -8,11 +8,6 @@ "title": "MCP-servers inschakelen", "description": "Indien ingeschakeld, kan Zoo communiceren met MCP-servers voor geavanceerde functionaliteit. Gebruik je geen MCP, dan kun je dit uitschakelen om het tokengebruik te verminderen." }, - "enableServerCreation": { - "title": "Aanmaken van MCP-server inschakelen", - "description": "Indien ingeschakeld, kan Zoo je helpen nieuwe MCP-servers te maken via commando's zoals 'voeg een nieuwe tool toe aan...'. Heb je dit niet nodig, schakel het dan uit om het tokengebruik te verminderen.", - "hint": "Tip: Zet deze instelling uit als je Zoo niet actief vraagt om een nieuwe MCP-server te maken, om API-tokenkosten te besparen." - }, "editGlobalMCP": "Globale MCP bewerken", "editProjectMCP": "Project-MCP bewerken", "learnMoreEditingSettings": "Meer over het bewerken van MCP-instellingen", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 5c36d91e30..d517df4bd0 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-sleutel", "getMoonshotApiKey": "Moonshot API-sleutel ophalen", "moonshotBaseUrl": "Moonshot-ingangspunt", + "kimiCode": { + "authMethod": "Authenticatiemethode", + "oauth": "Kimi Code-abonnement (OAuth)", + "apiKey": "Kimi Code API-sleutel", + "apiKeyLabel": "Kimi Code API-sleutel", + "signIn": "Inloggen bij Kimi Code", + "signOut": "Uitloggen", + "authenticated": "Ingelogd bij Kimi Code", + "deviceCodeHelp": "Voer deze apparaatcode in op de Kimi-autorisatiepagina:", + "docs": "Kimi Code-documentatie" + }, "zaiApiKey": "Z AI API-sleutel", "getZaiApiKey": "Z AI API-sleutel ophalen", "zaiEntrypoint": "Z AI-ingangspunt", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Beperk het maximale aantal tokens in het antwoord", "maxOutputTokensLabel": "Maximale output tokens", "maxTokensGenerateDescription": "Maximale tokens om te genereren in het antwoord", + "openAiCodexSpeed": { + "label": "Snelheid", + "tooltip": "De snelle modus gebruikt prioriteitsverwerking van Codex voor ongeveer 1,5 keer hogere snelheid en verbruikt meer abonnementquotum.", + "standard": "Standaard", + "fast": "Snel (1,5 keer hogere snelheid, meer verbruik)" + }, "serviceTier": { "label": "Serviceniveau", "tooltip": "Voor snellere verwerking van API-verzoeken, probeer het prioriteitsverwerkingsniveau. Voor lagere prijzen met hogere latentie, probeer het flexverwerkingsniveau.", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 46da82306c..505d8b766c 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Inteligentnie skondensuj kontekst", "openApiHistory": "Otwórz historię API", "openUiHistory": "Otwórz historię UI", - "backToParentTask": "Zadanie nadrzędne" + "backToParentTask": "Zadanie nadrzędne", + "waitingOnSubtask": "Oczekuje na podzadanie", + "goToSubtask": "Przejdź do podzadania" }, "unpin": "Odepnij", "pin": "Przypnij", @@ -185,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", @@ -195,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", @@ -341,9 +343,9 @@ "support": "Wesprzyj Zoo Code, dając nam gwiazdkę na GitHub.", "release": { "heading": "Co nowego:", - "highlight1": "Rodzina OpenAI GPT-5.6 (Sol, Terra, Luna) — dostępna zarówno w OpenAI Codex, jak i OpenAI Native.", - "highlight2": "Obsługa Grok 4.5 — nowy flagowy model xAI, a także poprawka reasoning effort, z której korzysta również Grok 4 Mini.", - "highlight3": "Provider Kenari — wysokiej klasy gateway AI kompatybilny z OpenAI, rozliczany w rupii, obsługujący Claude, GPT, DeepSeek, GLM, Kimi i więcej." + "highlight1": "Providerzy Moonshot i Kimi Code — łącz się z modelami Moonshot dzięki wykrywaniu modeli na żywo lub zaloguj się do Kimi Code przez przepływ urządzenia OAuth.", + "highlight2": "Obsługa najnowszych modeli — korzystaj z Claude Opus 5 u różnych providerów, a także z Kimi K3, Gemini 3.6 Flash i MiniMax-M3.", + "highlight3": "Bardziej niezawodne workflow — bezpiecznie porzucaj przerwane podzadania i indeksuj pliki Dart oraz pliki tekstowe w swojej bazie kodu." }, "cloudAgents": { "heading": "Nowości w chmurze:", diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 7ec4b40d8f..2924d4710e 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Podzadanie", "deleteWithSubtasks": "Spowoduje to usunięcie {{count}} podzadania(ń). Jesteś pewny?", "expandSubtasks": "Rozwiń podzadania", - "collapseSubtasks": "Zwiń podzadania" + "collapseSubtasks": "Zwiń podzadania", + "delegatedTag": "Oczekuje na podzadanie", + "interruptedTag": "Przerwane" } diff --git a/webview-ui/src/i18n/locales/pl/mcp.json b/webview-ui/src/i18n/locales/pl/mcp.json index 5d7ab75550..4854910d5d 100644 --- a/webview-ui/src/i18n/locales/pl/mcp.json +++ b/webview-ui/src/i18n/locales/pl/mcp.json @@ -8,11 +8,6 @@ "title": "Włącz serwery MCP", "description": "Włącz to, aby Zoo mógł korzystać z narzędzi połączonych serwerów MCP. Daje to Zoo więcej możliwości. Jeśli nie planujesz korzystać z tych dodatkowych narzędzi, wyłącz to, aby zmniejszyć koszty tokenów API." }, - "enableServerCreation": { - "title": "Włącz tworzenie serwerów MCP", - "description": "Włącz to, aby Zoo mógł pomóc ci tworzyć <1>nowe niestandardowe serwery MCP. <0>Dowiedz się więcej o tworzeniu serwerów", - "hint": "Wskazówka: Aby zmniejszyć koszty tokenów API, wyłącz tę opcję, gdy nie prosisz Zoo o utworzenie nowego serwera MCP." - }, "editGlobalMCP": "Edytuj globalny MCP", "editProjectMCP": "Edytuj MCP projektu", "learnMoreEditingSettings": "Dowiedz się więcej o edycji plików ustawień MCP", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 9f9da60b68..3ef8e06c32 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Klucz API Moonshot", "getMoonshotApiKey": "Uzyskaj klucz API Moonshot", "moonshotBaseUrl": "Punkt wejścia Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Klucz API Z AI", "getZaiApiKey": "Uzyskaj klucz API Z AI", "zaiEntrypoint": "Punkt wejścia Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Ogranicz maksymalną liczbę tokenów w odpowiedzi", "maxOutputTokensLabel": "Maksymalne tokeny wyjściowe", "maxTokensGenerateDescription": "Maksymalne tokeny do wygenerowania w odpowiedzi", + "openAiCodexSpeed": { + "label": "Szybkość", + "tooltip": "Tryb szybki korzysta z priorytetowego przetwarzania Codex, zapewniając około 1,5 raza większą szybkość i zużywając więcej limitu subskrypcji.", + "standard": "Standardowy", + "fast": "Szybki (1,5 raza szybciej, większe użycie)" + }, "serviceTier": { "label": "Poziom usług", "tooltip": "Aby szybciej przetwarzać żądania API, wypróbuj priorytetowy poziom usług. Aby uzyskać niższe ceny przy wyższej latencji, wypróbuj elastyczny poziom usług.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 83452930d6..45e7b227ce 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar contexto de forma inteligente", "openApiHistory": "Abrir histórico da API", "openUiHistory": "Abrir histórico da UI", - "backToParentTask": "Tarefa pai" + "backToParentTask": "Tarefa pai", + "waitingOnSubtask": "Aguardando subtarefa", + "goToSubtask": "Ir para subtarefa" }, "unpin": "Desfixar", "pin": "Fixar", @@ -185,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", @@ -195,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", @@ -341,9 +343,9 @@ "support": "Apoie o Zoo Code nos dando uma estrela no GitHub.", "release": { "heading": "Novidades:", - "highlight1": "Família OpenAI GPT-5.6 (Sol, Terra, Luna) — disponível tanto no OpenAI Codex quanto no OpenAI Native.", - "highlight2": "Suporte ao Grok 4.5 — o novo modelo principal da xAI, além de uma correção no reasoning effort que também beneficia o Grok 4 Mini.", - "highlight3": "Provider Kenari — um gateway de IA de primeira classe compatível com OpenAI, cobrado em rúpias, cobrindo Claude, GPT, DeepSeek, GLM, Kimi e mais." + "highlight1": "Providers Moonshot e Kimi Code — conecte-se aos modelos Moonshot com descoberta de modelos em tempo real ou entre no Kimi Code pelo fluxo de dispositivo OAuth.", + "highlight2": "Suporte aos modelos mais recentes — use Claude Opus 5 em vários providers, além de Kimi K3, Gemini 3.6 Flash e MiniMax-M3.", + "highlight3": "Workflows mais confiáveis — abandone subtarefas interrompidas de forma limpa e indexe arquivos Dart e de texto simples na sua base de código." }, "cloudAgents": { "heading": "Novidades na Nuvem:", diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 7966df1f46..79c84b70ef 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtarefa", "deleteWithSubtasks": "Isso também excluirá {{count}} subtarefa(s). Tem certeza?", "expandSubtasks": "Expandir subtarefas", - "collapseSubtasks": "Recolher subtarefas" + "collapseSubtasks": "Recolher subtarefas", + "delegatedTag": "Aguardando subtarefa", + "interruptedTag": "Interrompida" } diff --git a/webview-ui/src/i18n/locales/pt-BR/mcp.json b/webview-ui/src/i18n/locales/pt-BR/mcp.json index 70b48d0130..a6acac9283 100644 --- a/webview-ui/src/i18n/locales/pt-BR/mcp.json +++ b/webview-ui/src/i18n/locales/pt-BR/mcp.json @@ -8,11 +8,6 @@ "title": "Ativar servidores MCP", "description": "Ative para que o Zoo possa usar ferramentas de servidores MCP conectados. Isso dá mais capacidades ao Zoo. Se você não pretende usar essas ferramentas extras, desative para ajudar a reduzir os custos de tokens da API." }, - "enableServerCreation": { - "title": "Ativar criação de servidores MCP", - "description": "Ative para que o Zoo possa te ajudar a criar <1>novos servidores MCP personalizados. <0>Saiba mais sobre criação de servidores", - "hint": "Dica: Para reduzir os custos de tokens da API, desative esta configuração quando não estiver pedindo ao Zoo para criar um novo servidor MCP." - }, "editGlobalMCP": "Editar MCP global", "editProjectMCP": "Editar MCP do projeto", "learnMoreEditingSettings": "Saiba mais sobre como editar arquivos de configuração MCP", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b267f41573..9c67418d16 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Chave de API Moonshot", "getMoonshotApiKey": "Obter chave de API Moonshot", "moonshotBaseUrl": "Ponto de entrada Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Chave de API Z AI", "getZaiApiKey": "Obter chave de API Z AI", "zaiEntrypoint": "Ponto de entrada Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Limitar o número máximo de tokens na resposta", "maxOutputTokensLabel": "Tokens máximos de saída", "maxTokensGenerateDescription": "Tokens máximos para gerar na resposta", + "openAiCodexSpeed": { + "label": "Velocidade", + "tooltip": "O modo rápido usa o processamento prioritário do Codex para oferecer cerca de 1,5 vez mais velocidade e consome mais cota da assinatura.", + "standard": "Padrão", + "fast": "Rápido (1,5 vez mais velocidade, maior uso)" + }, "serviceTier": { "label": "Nível de serviço", "tooltip": "Para um processamento mais rápido das solicitações de API, experimente o nível de serviço de processamento prioritário. Para preços mais baixos com maior latência, experimente o nível de processamento flexível.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 4e09e1e704..5b8ae0da19 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Интеллектуально сжать контекст", "openApiHistory": "Открыть историю API", "openUiHistory": "Открыть историю UI", - "backToParentTask": "Родительская задача" + "backToParentTask": "Родительская задача", + "waitingOnSubtask": "Ожидание подзадачи", + "goToSubtask": "Перейти к подзадаче" }, "unpin": "Открепить", "pin": "Закрепить", @@ -180,7 +182,7 @@ "current": "Текущая" }, "instructions": { - "wantsToFetch": "Roo хочет получить подробные инструкции для помощи с текущей задачей" + "wantsToFetch": "Zoo хочет получить подробные инструкции для помощи с текущей задачей" }, "fileOperations": { "wantsToRead": "Zoo хочет прочитать этот файл", @@ -190,8 +192,8 @@ "wantsToEditOutsideWorkspace": "Zoo хочет отредактировать этот файл вне рабочей области", "wantsToEditProtected": "Zoo хочет отредактировать защищённый файл конфигурации", "wantsToCreate": "Zoo хочет создать новый файл", - "wantsToSearchReplace": "Roo хочет выполнить поиск и замену в этом файле", - "didSearchReplace": "Roo выполнил поиск и замену в этом файле", + "wantsToSearchReplace": "Zoo хочет выполнить поиск и замену в этом файле", + "didSearchReplace": "Zoo выполнил поиск и замену в этом файле", "wantsToInsert": "Zoo хочет вставить содержимое в этот файл", "wantsToInsertWithLineNumber": "Zoo хочет вставить содержимое в этот файл на строку {{lineNumber}}", "wantsToInsertAtEnd": "Zoo хочет добавить содержимое в конец этого файла", @@ -240,7 +242,7 @@ "response": "Ответ", "arguments": "Аргументы", "text": { - "rooSaid": "Ру сказал" + "rooSaid": "Zoo сказал" }, "feedback": { "youSaid": "Вы сказали" @@ -315,9 +317,9 @@ "support": "Поддержите Zoo Code, поставив нам звезду на GitHub.", "release": { "heading": "Что нового:", - "highlight1": "Семейство OpenAI GPT-5.6 (Sol, Terra, Luna) — доступно как в OpenAI Codex, так и в OpenAI Native.", - "highlight2": "Поддержка Grok 4.5 — новая флагманская модель xAI, а также исправление reasoning effort, от которого выигрывает и Grok 4 Mini.", - "highlight3": "Провайдер Kenari — первоклассный AI-шлюз, совместимый с OpenAI, с выставлением счётов в рупиях, поддерживающий Claude, GPT, DeepSeek, GLM, Kimi и другие модели." + "highlight1": "Провайдеры Moonshot и Kimi Code — подключайтесь к моделям Moonshot с динамическим обнаружением моделей или входите в Kimi Code через OAuth-поток для устройств.", + "highlight2": "Поддержка новейших моделей — используйте Claude Opus 5 у разных провайдеров, а также Kimi K3, Gemini 3.6 Flash и MiniMax-M3.", + "highlight3": "Более надёжные рабочие процессы — безопасно завершайте прерванные подзадачи и индексируйте файлы Dart и обычные текстовые файлы в кодовой базе." }, "cloudAgents": { "heading": "Новое в облаке:", @@ -380,7 +382,7 @@ "autoApprovedCostLimitReached": { "title": "Достигнут лимит автоматически одобряемых расходов", "button": "Сбросить и продолжить", - "description": "Ру достиг автоматически утвержденного лимита расходов в размере ${{count}}. Хотите сбросить расходы и продолжить выполнение задачи?" + "description": "Zoo достиг автоматически утвержденного лимита расходов в размере ${{count}}. Хотите сбросить расходы и продолжить выполнение задачи?" } }, "codebaseSearch": { diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 7852362348..3035ec59d9 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Подзадача", "deleteWithSubtasks": "Это также удалит {{count}} подзадачу(и). Вы уверены?", "expandSubtasks": "Развернуть подзадачи", - "collapseSubtasks": "Свернуть подзадачи" + "collapseSubtasks": "Свернуть подзадачи", + "delegatedTag": "Ожидание подзадачи", + "interruptedTag": "Прервано" } diff --git a/webview-ui/src/i18n/locales/ru/mcp.json b/webview-ui/src/i18n/locales/ru/mcp.json index e67e063f4c..0322016bf4 100644 --- a/webview-ui/src/i18n/locales/ru/mcp.json +++ b/webview-ui/src/i18n/locales/ru/mcp.json @@ -8,11 +8,6 @@ "title": "Включить серверы MCP", "description": "Включи, чтобы Zoo мог использовать инструменты с подключённых серверов MCP. Это даст Zoo больше возможностей. Если не планируешь использовать эти дополнительные инструменты, выключи для экономии токенов API." }, - "enableServerCreation": { - "title": "Включить создание серверов MCP", - "description": "Включи, чтобы Zoo помогал создавать <1>новые кастомные серверы MCP. <0>Подробнее о создании серверов", - "hint": "Совет: чтобы снизить расходы на токены API, отключай эту настройку, когда не просишь Zoo создать новый сервер MCP." - }, "editGlobalMCP": "Редактировать глобальный MCP", "editProjectMCP": "Редактировать проектный MCP", "learnMoreEditingSettings": "Подробнее о редактировании файлов настроек MCP", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2d427202f9..6d81073dbe 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": "Автоодобрение отключено - сначала выберите опции", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API-ключ", "getMoonshotApiKey": "Получить Moonshot API-ключ", "moonshotBaseUrl": "Точка входа Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API-ключ", "getZaiApiKey": "Получить Z AI API-ключ", "zaiEntrypoint": "Точка входа Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Ограничить максимальное количество токенов в ответе", "maxOutputTokensLabel": "Максимальные выходные токены", "maxTokensGenerateDescription": "Максимальные токены для генерации в ответе", + "openAiCodexSpeed": { + "label": "Скорость", + "tooltip": "Быстрый режим использует приоритетную обработку Codex, обеспечивая примерно 1,5-кратную скорость и расходуя больше квоты подписки.", + "standard": "Стандартный", + "fast": "Быстрый (скорость 1,5×, повышенный расход)" + }, "serviceTier": { "label": "Уровень обслуживания", "tooltip": "Для более быстрой обработки запросов API попробуйте уровень обслуживания с приоритетной обработкой. Для более низких цен с более высокой задержкой попробуйте уровень гибкой обработки.", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 88f6c25968..a5c0bac756 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Bağlamı akıllıca yoğunlaştır", "openApiHistory": "API Geçmişini Aç", "openUiHistory": "UI Geçmişini Aç", - "backToParentTask": "Üst görev" + "backToParentTask": "Üst görev", + "waitingOnSubtask": "Alt görev bekleniyor", + "goToSubtask": "Alt göreve git" }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", @@ -185,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", @@ -195,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", @@ -342,9 +344,9 @@ "support": "Lütfen GitHub'da bize yıldız vererek Zoo Code'u destekle.", "release": { "heading": "Yenilikler:", - "highlight1": "OpenAI GPT-5.6 ailesi (Sol, Terra, Luna) — hem OpenAI Codex hem de OpenAI Native üzerinde kullanılabilir.", - "highlight2": "Grok 4.5 desteği — xAI'nin yeni amiral gemisi modeli, ayrıca Grok 4 Mini'ye de fayda sağlayan bir reasoning effort düzeltmesi.", - "highlight3": "Kenari provider — Rupiah üzerinden faturalandırılan, Claude, GPT, DeepSeek, GLM, Kimi ve daha fazlasını kapsayan, OpenAI uyumlu birinci sınıf bir AI ağ geçidi." + "highlight1": "Moonshot ve Kimi Code provider'ları — canlı model keşfiyle Moonshot modellerine bağlan veya OAuth cihaz akışıyla Kimi Code'da oturum aç.", + "highlight2": "En yeni model desteği — provider'lar genelinde Claude Opus 5'in yanı sıra Kimi K3, Gemini 3.6 Flash ve MiniMax-M3'ü kullan.", + "highlight3": "Daha güvenilir iş akışları — kesintiye uğramış alt görevleri temiz biçimde bırak ve kod tabanındaki Dart ile düz metin dosyalarını indeksle." }, "cloudAgents": { "heading": "Cloud'daki yenilikler:", diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index fb7b6c6832..2ebc1a0154 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Alt görev", "deleteWithSubtasks": "Bu, {{count}} alt görev(i) de silecektir. Emin misiniz?", "expandSubtasks": "Alt görevleri genişlet", - "collapseSubtasks": "Alt görevleri daralt" + "collapseSubtasks": "Alt görevleri daralt", + "delegatedTag": "Alt görev bekleniyor", + "interruptedTag": "Kesintiye uğradı" } diff --git a/webview-ui/src/i18n/locales/tr/mcp.json b/webview-ui/src/i18n/locales/tr/mcp.json index 7aab5b8a4e..691e30c1dc 100644 --- a/webview-ui/src/i18n/locales/tr/mcp.json +++ b/webview-ui/src/i18n/locales/tr/mcp.json @@ -8,11 +8,6 @@ "title": "MCP Sunucularını Etkinleştir", "description": "Bunu AÇ, böylece Zoo bağlı MCP sunucularından araçlar kullanabilir. Zoo'ya daha fazla yetenek kazandırır. Ekstra araçları kullanmayacaksan, API token maliyetini azaltmak için bunu KAPAT." }, - "enableServerCreation": { - "title": "MCP Sunucu Oluşturmayı Etkinleştir", - "description": "Bunu AÇ, Zoo'nun <1>yeni özel MCP sunucuları oluşturmanda sana yardımcı olmasını sağlar. <0>Sunucu oluşturma hakkında bilgi al", - "hint": "İpucu: API token maliyetini azaltmak için Zoo'dan yeni bir MCP sunucusu oluşturmasını istemediğinde bu ayarı kapat." - }, "editGlobalMCP": "Global MCP'yi Düzenle", "editProjectMCP": "Proje MCP'sini Düzenle", "learnMoreEditingSettings": "MCP ayar dosyalarını düzenleme hakkında daha fazla bilgi", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index ba4b092cf3..0456367efc 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API Anahtarı", "getMoonshotApiKey": "Moonshot API Anahtarı Al", "moonshotBaseUrl": "Moonshot Giriş Noktası", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Z AI API Anahtarı", "getZaiApiKey": "Z AI API Anahtarı Al", "zaiEntrypoint": "Z AI Giriş Noktası", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Yanıttaki maksimum token sayısını sınırla", "maxOutputTokensLabel": "Maksimum çıktı tokenları", "maxTokensGenerateDescription": "Yanıtta oluşturulacak maksimum token sayısı", + "openAiCodexSpeed": { + "label": "Hız", + "tooltip": "Hızlı mod, yaklaşık 1,5 kat hız için Codex öncelikli işlemeyi kullanır ve daha fazla abonelik kotası tüketir.", + "standard": "Standart", + "fast": "Hızlı (1,5 kat hız, daha fazla kullanım)" + }, "serviceTier": { "label": "Hizmet seviyesi", "tooltip": "Daha hızlı API isteği işleme için öncelikli işleme hizmeti seviyesini deneyin. Daha düşük gecikme süresiyle daha düşük fiyatlar için esnek işleme seviyesini deneyin.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index f506962395..b5da186e43 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Cô đọng ngữ cảnh thông minh", "openApiHistory": "Mở lịch sử API", "openUiHistory": "Mở lịch sử UI", - "backToParentTask": "Nhiệm vụ cha" + "backToParentTask": "Nhiệm vụ cha", + "waitingOnSubtask": "Đang chờ nhiệm vụ con", + "goToSubtask": "Đến nhiệm vụ con" }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", @@ -185,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", @@ -195,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", @@ -342,9 +344,9 @@ "support": "Hãy ủng hộ Zoo Code bằng cách tặng sao cho chúng tôi trên GitHub.", "release": { "heading": "Có gì mới:", - "highlight1": "Dòng OpenAI GPT-5.6 (Sol, Terra, Luna) — có sẵn trên cả OpenAI Codex và OpenAI Native.", - "highlight2": "Hỗ trợ Grok 4.5 — mẫu chủ lực mới của xAI, cùng với bản sửa lỗi reasoning effort cũng có lợi cho Grok 4 Mini.", - "highlight3": "Provider Kenari — gateway AI hạng nhất tương thích OpenAI, tính phí bằng Rupiah, hỗ trợ Claude, GPT, DeepSeek, GLM, Kimi và nhiều hơn nữa." + "highlight1": "Provider Moonshot và Kimi Code — kết nối với các model Moonshot bằng tính năng khám phá model trực tiếp hoặc đăng nhập Kimi Code qua luồng thiết bị OAuth.", + "highlight2": "Hỗ trợ model mới nhất — dùng Claude Opus 5 trên nhiều provider, cùng với Kimi K3, Gemini 3.6 Flash và MiniMax-M3.", + "highlight3": "Workflow đáng tin cậy hơn — từ bỏ gọn gàng các tác vụ con bị gián đoạn và lập chỉ mục file Dart cùng file văn bản thuần trong codebase." }, "cloudAgents": { "heading": "Mới trên Cloud:", diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index 779953e540..a6efa0671e 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Tác vụ con", "deleteWithSubtasks": "Điều này cũng sẽ xóa {{count}} tác vụ con. Bạn có chắc không?", "expandSubtasks": "Mở rộng tác vụ con", - "collapseSubtasks": "Thu gọn tác vụ con" + "collapseSubtasks": "Thu gọn tác vụ con", + "delegatedTag": "Đang chờ nhiệm vụ con", + "interruptedTag": "Bị gián đoạn" } diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index 49becffa6f..c5bb53c7e1 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -8,11 +8,6 @@ "title": "Bật máy chủ MCP", "description": "Bật lên để Zoo dùng công cụ từ các máy chủ MCP đã kết nối. Zoo sẽ có nhiều khả năng hơn. Nếu không dùng các công cụ này, hãy tắt để tiết kiệm chi phí token API." }, - "enableServerCreation": { - "title": "Bật tạo máy chủ MCP", - "description": "Bật lên để Zoo giúp bạn tạo <1>máy chủ MCP mới tuỳ chỉnh. <0>Tìm hiểu về tạo máy chủ", - "hint": "Mẹo: Để giảm chi phí token API, hãy tắt khi không cần Zoo tạo máy chủ MCP mới." - }, "editGlobalMCP": "Chỉnh sửa MCP toàn cục", "editProjectMCP": "Chỉnh sửa MCP dự án", "learnMoreEditingSettings": "Tìm hiểu thêm về chỉnh sửa file cài đặt MCP", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 56a04f6c59..4beb3f7171 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", @@ -459,6 +459,17 @@ "moonshotApiKey": "Khóa API Moonshot", "getMoonshotApiKey": "Lấy khóa API Moonshot", "moonshotBaseUrl": "Điểm vào Moonshot", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "zaiApiKey": "Khóa API Z AI", "getZaiApiKey": "Lấy khóa API Z AI", "zaiEntrypoint": "Điểm vào Z AI", @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "Giới hạn số lượng token tối đa trong phản hồi", "maxOutputTokensLabel": "Token đầu ra tối đa", "maxTokensGenerateDescription": "Token tối đa để tạo trong phản hồi", + "openAiCodexSpeed": { + "label": "Tốc độ", + "tooltip": "Chế độ Nhanh sử dụng xử lý ưu tiên của Codex để đạt tốc độ nhanh hơn khoảng 1,5 lần và tiêu tốn nhiều hạn mức đăng ký hơn.", + "standard": "Tiêu chuẩn", + "fast": "Nhanh (tốc độ 1,5 lần, mức sử dụng cao hơn)" + }, "serviceTier": { "label": "Cấp độ dịch vụ", "tooltip": "Để xử lý các yêu cầu API nhanh hơn, hãy thử cấp độ dịch vụ xử lý ưu tiên. Để có giá thấp hơn với độ trễ cao hơn, hãy thử cấp độ xử lý linh hoạt.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 286e49c8f7..63b8ac69fd 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -17,7 +17,9 @@ "condenseContext": "智能压缩上下文", "openApiHistory": "打开 API 历史", "openUiHistory": "打开 UI 历史", - "backToParentTask": "父任务" + "backToParentTask": "父任务", + "waitingOnSubtask": "等待子任务", + "goToSubtask": "前往子任务" }, "unpin": "取消置顶", "pin": "置顶", @@ -128,7 +130,7 @@ "title": "模式", "marketplace": "模式市场", "settings": "模式设置", - "description": "专门定制Roo行为的角色。", + "description": "专门定制Zoo行为的角色。", "searchPlaceholder": "搜索模式...", "noResults": "未找到结果" }, @@ -142,7 +144,7 @@ "diffError": { "title": "编辑失败" }, - "troubleMessage": "Roo遇到问题...", + "troubleMessage": "Zoo遇到问题...", "apiRequest": { "title": "API请求", "failed": "API请求失败", @@ -185,7 +187,7 @@ "current": "当前" }, "instructions": { - "wantsToFetch": "Roo 想要获取详细指示以协助当前任务" + "wantsToFetch": "Zoo 想要获取详细指示以协助当前任务" }, "fileOperations": { "wantsToRead": "需要读取文件", @@ -251,8 +253,8 @@ "youSaid": "你说" }, "mcp": { - "wantsToUseTool": "Roo想在{{serverName}} MCP上使用工具", - "wantsToAccessResource": "Roo想访问{{serverName}} MCP服务上的资源" + "wantsToUseTool": "Zoo想在{{serverName}} MCP上使用工具", + "wantsToAccessResource": "Zoo想访问{{serverName}} MCP服务上的资源" }, "modes": { "wantsToSwitch": "即将切换至{{mode}}模式", @@ -261,8 +263,8 @@ "didSwitchWithReason": "已切换至{{mode}}模式(原因:{{reason}})" }, "subtasks": { - "wantsToCreate": "Roo想在{{mode}}模式下创建新子任务", - "wantsToFinish": "Roo想完成此子任务", + "wantsToCreate": "Zoo想在{{mode}}模式下创建新子任务", + "wantsToFinish": "Zoo想完成此子任务", "newTaskContent": "子任务说明", "completionContent": "子任务已完成", "resultContent": "子任务结果", @@ -271,7 +273,7 @@ "goToSubtask": "查看任务" }, "questions": { - "hasQuestion": "Roo有一个问题" + "hasQuestion": "Zoo有一个问题" }, "taskCompleted": "任务完成", "modelResponseIncomplete": "模型响应不完整", @@ -342,9 +344,9 @@ "support": "请在 GitHub 上为 Zoo Code 点星支持我们。", "release": { "heading": "新增功能:", - "highlight1": "OpenAI GPT-5.6 系列(Sol、Terra、Luna)— 已在 OpenAI Codex 与 OpenAI Native 中同时提供。", - "highlight2": "支持 Grok 4.5 — xAI 全新旗舰模型,同时修复了 reasoning effort 问题,Grok 4 Mini 也因此受益。", - "highlight3": "Kenari 提供商 — 以卢比计费的一流 OpenAI 兼容 AI 网关,支持 Claude、GPT、DeepSeek、GLM、Kimi 等。" + "highlight1": "Moonshot 和 Kimi Code 提供商 — 通过实时模型发现连接 Moonshot 模型,或使用 OAuth 设备流程登录 Kimi Code。", + "highlight2": "最新模型支持 — 跨提供商使用 Claude Opus 5,以及 Kimi K3、Gemini 3.6 Flash 和 MiniMax-M3。", + "highlight3": "更可靠的工作流 — 干净地放弃中断的子任务,并为代码库中的 Dart 和纯文本文件建立索引。" }, "cloudAgents": { "heading": "云端新功能:", @@ -379,7 +381,7 @@ }, "autoApprovedCostLimitReached": { "title": "已达到自动批准的费用限额", - "description": "Roo已经达到了${{count}}的自动批准成本限制。您想重置成本并继续任务吗?", + "description": "Zoo已经达到了${{count}}的自动批准成本限制。您想重置成本并继续任务吗?", "button": "重置并继续" } }, diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 20a73240ea..6b6bd03300 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -47,5 +47,7 @@ "subtaskTag": "子任务", "deleteWithSubtasks": "这也将删除 {{count}} 个子任务。您确定吗?", "expandSubtasks": "展开子任务", - "collapseSubtasks": "收起子任务" + "collapseSubtasks": "收起子任务", + "delegatedTag": "等待子任务", + "interruptedTag": "已中断" } diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index 652ab77dec..070a3a9e3b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -8,11 +8,6 @@ "title": "启用 MCP 服务器", "description": "开启后 Zoo 可用已连接 MCP 服务器的工具,能力更强。不用这些工具时建议关闭,节省 API Token 费用。" }, - "enableServerCreation": { - "title": "启用 MCP 服务器创建", - "description": "开启后 Zoo 可帮你创建<1>新自定义 MCP 服务器。<0>了解服务器创建", - "hint": "提示:不需要 Zoo 创建新 MCP 服务器时建议关闭,减少 API Token 费用。" - }, "editGlobalMCP": "编辑全局 MCP", "editProjectMCP": "编辑项目 MCP", "learnMoreEditingSettings": "了解如何编辑 MCP 设置文件", 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 03fc075da9..8624c1899b 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": "自动批准已禁用 - 请先选择选项", @@ -459,6 +459,17 @@ "moonshotApiKey": "Moonshot API 密钥", "getMoonshotApiKey": "获取 Moonshot API 密钥", "moonshotBaseUrl": "Moonshot 服务站点", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API 密钥", "getMiniMaxApiKey": "获取 MiniMax API 密钥", "minimaxBaseUrl": "MiniMax 服务站点", @@ -605,7 +616,7 @@ }, "consecutiveMistakeLimit": { "label": "错误和重复限制", - "description": "在显示“Roo遇到问题”对话框前允许的连续错误或重复操作次数。设置为 0 可禁用此安全机制(它将永远不会触发)。", + "description": "在显示“Zoo遇到问题”对话框前允许的连续错误或重复操作次数。设置为 0 可禁用此安全机制(它将永远不会触发)。", "unlimitedDescription": "已启用无限重试(自动继续)。对话框将永远不会出现。", "warning": "⚠️ 设置为 0 允许无限重试,这可能会消耗大量 API 使用量" }, @@ -1035,6 +1046,12 @@ "limitMaxTokensDescription": "限制响应中的最大 Token 数量", "maxOutputTokensLabel": "最大输出 Token 数", "maxTokensGenerateDescription": "响应中生成的最大 Token 数", + "openAiCodexSpeed": { + "label": "速度", + "tooltip": "快速模式使用 Codex 优先处理,速度约为 1.5 倍,但会消耗更多订阅配额。", + "standard": "标准", + "fast": "快速(1.5 倍速度,用量增加)" + }, "serviceTier": { "label": "服务等级", "tooltip": "为加快API请求处理速度,请尝试优先处理服务等级。为获得更低价格但延迟较高,请尝试灵活处理等级。", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 6b6aeba6fe..dbea150f29 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -17,7 +17,9 @@ "delete": "刪除工作(按住 Shift 並點選可跳過確認)", "openApiHistory": "開啟 API 歷史紀錄", "openUiHistory": "開啟 UI 歷史紀錄", - "backToParentTask": "上層工作" + "backToParentTask": "上層工作", + "waitingOnSubtask": "等待子任務", + "goToSubtask": "前往子任務" }, "unpin": "取消釘選", "pin": "釘選", @@ -226,7 +228,7 @@ "didLoad": "Zoo 載入了技能" }, "instructions": { - "wantsToFetch": "Roo 想要取得詳細指示以協助目前工作" + "wantsToFetch": "Zoo 想要取得詳細指示以協助目前工作" }, "fileOperations": { "wantsToRead": "Zoo 想要讀取此檔案", @@ -363,9 +365,9 @@ "support": "請在 GitHub 上為 Zoo Code 加星支持我們。", "release": { "heading": "新增功能:", - "highlight1": "OpenAI GPT-5.6 系列(Sol、Terra、Luna)— 已在 OpenAI Codex 與 OpenAI Native 中同時提供。", - "highlight2": "支援 Grok 4.5 — xAI 全新旗艦模型,同時修正了 reasoning effort 問題,Grok 4 Mini 也因此受惠。", - "highlight3": "Kenari 供應商 — 以盧比計費的一流 OpenAI 相容 AI 閘道,支援 Claude、GPT、DeepSeek、GLM、Kimi 等。" + "highlight1": "Moonshot 與 Kimi Code 供應商 — 透過即時模型探索連接 Moonshot 模型,或使用 OAuth 裝置流程登入 Kimi Code。", + "highlight2": "最新模型支援 — 跨供應商使用 Claude Opus 5,以及 Kimi K3、Gemini 3.6 Flash 與 MiniMax-M3。", + "highlight3": "更可靠的工作流程 — 乾淨地放棄中斷的子任務,並為程式碼庫中的 Dart 與純文字檔案建立索引。" }, "cloudAgents": { "heading": "雲端的新功能:", diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 1e12190a69..5fb3230c80 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -47,5 +47,7 @@ "subtaskTag": "子工作", "deleteWithSubtasks": "這也將刪除 {{count}} 個子工作。您確定嗎?", "expandSubtasks": "展開子工作", - "collapseSubtasks": "收起子工作" + "collapseSubtasks": "收起子工作", + "delegatedTag": "等待子任務", + "interruptedTag": "已中斷" } diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index ad6e67d22c..8a416d2992 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -8,11 +8,6 @@ "title": "啟用 MCP 伺服器", "description": "啟用此選項後,Zoo 將可使用已連線 MCP 伺服器所提供的工具,進一步提升功能。如果您暫無使用這些額外工具的需求,建議關閉此選項以協助降低 API Token 費用。" }, - "enableServerCreation": { - "title": "啟用 MCP 伺服器建立功能", - "description": "啟用此選項後,Zoo 可協助您建立<1>全新自訂 MCP 伺服器。<0>深入了解伺服器建立流程", - "hint": "提示:若您暫無建立新 MCP 伺服器的需求,建議停用此設定,以協助降低 API Token 費用。" - }, "editGlobalMCP": "編輯全域 MCP", "editProjectMCP": "編輯專案 MCP", "refreshMCP": "重新整理 MCP 伺服器", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index b84cb627e7..8556e8b2f4 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -486,6 +486,17 @@ "moonshotApiKey": "Moonshot API 金鑰", "getMoonshotApiKey": "取得 Moonshot API 金鑰", "moonshotBaseUrl": "Moonshot 服務端點", + "kimiCode": { + "authMethod": "Authentication method", + "oauth": "Kimi Code subscription (OAuth)", + "apiKey": "Kimi Code API key", + "apiKeyLabel": "Kimi Code API Key", + "signIn": "Sign in to Kimi Code", + "signOut": "Sign out", + "authenticated": "Signed in to Kimi Code", + "deviceCodeHelp": "Enter this device code in the Kimi authorization page:", + "docs": "Kimi Code documentation" + }, "minimaxApiKey": "MiniMax API 金鑰", "getMiniMaxApiKey": "取得 MiniMax API 金鑰", "minimaxBaseUrl": "MiniMax 服務端點", @@ -1062,6 +1073,12 @@ "limitMaxTokensDescription": "限制回應中的最大 Token 數量", "maxOutputTokensLabel": "最大輸出 Token 數", "maxTokensGenerateDescription": "回應中產生的最大 Token 數", + "openAiCodexSpeed": { + "label": "速度", + "tooltip": "快速模式使用 Codex 優先處理,速度約為 1.5 倍,但會消耗更多訂閱配額。", + "standard": "標準", + "fast": "快速(1.5 倍速度,用量增加)" + }, "serviceTier": { "label": "服務層級", "tooltip": "若需更快的 API 請求處理,請嘗試優先處理服務層級。若需較低價格但延遲較高,請嘗試彈性處理層級。", 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 04484397c6..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", () => ({ @@ -54,6 +54,8 @@ describe("Model Validation Functions", () => { "opencode-go": {}, kenari: {}, "zoo-gateway": {}, + "kimi-code": {}, + moonshot: {}, } const allowAllOrganization: OrganizationAllowList = { @@ -365,6 +367,48 @@ describe("Model Validation Functions", () => { }) }) }) + + describe("Kimi Code validation", () => { + it("returns undefined when using OAuth auth method", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "oauth", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns undefined when auth method is not specified (defaults to OAuth)", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + + it("returns apiKey error when using api-key auth method without key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.apiKey") + }) + + it("returns undefined when using api-key auth method with key", () => { + const config: ProviderSettings = { + apiProvider: "kimi-code", + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "valid-key", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + }) }) describe("validateBedrockArn", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index d780786808..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,112 +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 "vercel-ai-gateway": + case providerIdentifiers.kimiCode: + if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) { + return i18next.t("settings:validation.apiKey") + } + break + 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") } @@ -201,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 } @@ -312,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) { diff --git a/webview-ui/tsconfig.json b/webview-ui/tsconfig.json index 14b36b878b..e71653d66e 100644 --- a/webview-ui/tsconfig.json +++ b/webview-ui/tsconfig.json @@ -24,5 +24,5 @@ "@roo/*": ["../src/shared/*"] } }, - "include": ["src", "../src/shared", "vitest.setup.ts"] + "include": ["src", "playwright", "playwright-ct.config.ts", "../src/shared", "vitest.setup.ts"] } diff --git a/webview-ui/vitest.config.ts b/webview-ui/vitest.config.ts index ea1beea51d..9b43616c1d 100644 --- a/webview-ui/vitest.config.ts +++ b/webview-ui/vitest.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ reporters, silent, environment: "jsdom", - include: ["src/**/*.spec.ts", "src/**/*.spec.tsx"], + include: ["src/**/*.spec.{ts,tsx}", "src/**/*.test.{ts,tsx}"], onConsoleLog, maxWorkers: isCI ? 1 : undefined, testTimeout: isCI ? 15000 : 5000, @@ -33,6 +33,8 @@ export default defineConfig({ "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", + "**/*.visual.ts", + "**/*.visual.tsx", "**/vitest.setup.ts", "**/vitest.config.ts", "**/vite.config.ts",