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 f0e7ff0aa7..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 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/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..890c243b38 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,38 @@ 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" +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 +126,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 +165,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 +189,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 +215,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 +228,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({ @@ -342,7 +474,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 +486,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..641e02f6ac 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -5,7 +5,16 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "./test-utils" import { sleep, waitFor, waitUntilCompleted } from "./utils" import { + 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 +26,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 +102,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 +275,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 +359,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 +472,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 +784,165 @@ 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(() => {}) + } + }) }) 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/docs/260727_0001_session_split-pr-plan/142800_code-report.md b/docs/260727_0001_session_split-pr-plan/142800_code-report.md new file mode 100644 index 0000000000..cadc454a05 --- /dev/null +++ b/docs/260727_0001_session_split-pr-plan/142800_code-report.md @@ -0,0 +1,61 @@ +# Code Task Report +## Task Summary +Fixed platform-unit-test failures on `fix/providers-total-cost` branch. The CI "Run non-core coverage" step was failing due to test assertion mismatches after `totalCost` was added to all 11 providers in commit `ce77a10bd`. + +## Actions Taken + +### 1. Reproduced failures locally +Ran `vitest run api/providers/__tests__/` from `src/` directory. Found 6 failing test files with 7 failing tests: +- `friendli.spec.ts` - TypeError: Class extends value undefined (import cascade) +- `kimi-code.spec.ts` - TypeError: Class extends value undefined (import cascade) +- `moonshot.spec.ts` - TypeError: Class extends value undefined (root cause) +- `anthropic-vertex.spec.ts` - 2 toEqual failures (missing `totalCost`) +- `kenari.spec.ts` - 3 toEqual/toContainEqual failures (missing `totalCost`) +- `openai-usage-tracking.spec.ts` - 2 toEqual failures (missing `totalCost`) + +### 2. Root Cause Analysis + +**Root cause 1 (moonshot.ts - TypeError cascade):** +Commit `ce77a10bd` rewrote [`moonshot.ts`](src/api/providers/moonshot.ts:5) to import `OpenAiCompatibleHandler` and `OpenAiCompatibleHandlerOptions` from [`openai-compatible.ts`](src/api/providers/openai-compatible.ts:51). However, the actual exports are `OpenAICompatibleHandler` (capital "AI") and `OpenAICompatibleConfig`. The import name mismatch made `OpenAiCompatibleHandler` resolve to `undefined`, causing `class MoonshotHandler extends undefined` → TypeError. This cascaded through [`index.ts`](src/api/providers/index.ts:5) to break `friendli.spec.ts` and `kimi-code.spec.ts` which import from the providers index. + +**Root cause 2 (test assertion mismatches):** +The `totalCost` field was added to usage chunks in all providers, but several test files still used `toEqual()` with the old expected objects that didn't include `totalCost`. Since `toEqual` requires exact matches, the extra `totalCost` field caused assertion failures. + +### 3. Fixes Applied + +**Fix 1: [`moonshot.ts`](src/api/providers/moonshot.ts) - Restored to extend `OpenAiHandler`** +Reverted moonshot.ts to its pre-commit structure (extending `OpenAiHandler` from `openai.ts`), and added `totalCost` calculation to the existing `processUsageMetrics` method. This preserves the original working architecture while adding the `totalCost` feature. + +**Fix 2: [`anthropic-vertex.spec.ts`](src/api/providers/__tests__/anthropic-vertex.spec.ts:216) - Updated toEqual assertions** +- Line 216: Added `totalCost: expect.any(Number)` to message_start usage chunk assertion +- Line 429: Added `totalCost: expect.any(Number)` to prompt caching usage chunk assertion + +**Fix 3: [`openai-usage-tracking.spec.ts`](src/api/providers/__tests__/openai-usage-tracking.spec.ts:137) - Updated toEqual assertions** +- Line 137: Added `totalCost: 0` to usage chunk assertion (model has no pricing) +- Line 197: Added `totalCost: 0` to usage chunk assertion (model has no pricing) + +**Fix 4: [`kenari.spec.ts`](src/api/providers/__tests__/kenari.spec.ts:139) - Updated toContainEqual/toEqual assertions** +- Line 139: Added `totalCost: 0` to usage chunk assertion +- Line 207: Added `totalCost: 0` to usage chunk assertion +- Line 316: Added `totalCost: 0` to usage chunk assertion + +## Result +✅ Success - All tests pass + +Evidence: +- Provider tests: 54 test files passed, 1198 tests passed (1 skipped) +- Full src test suite: 427 test files passed (3 skipped), 7087 tests passed (37 skipped), 0 failures + +## Issues Discovered +- The `ce77a10bd` commit had a fundamental import error in `moonshot.ts` that would have broken the Moonshot provider at runtime (not just tests). The import referenced `OpenAiCompatibleHandler` but the exported class is `OpenAICompatibleHandler`. This was likely never caught because the test suite wasn't run before the commit. + +## Next Step Recommendations +- VP should commit these fixes +- Consider adding a pre-commit hook that runs `vitest run api/providers/__tests__/` to catch test failures before they reach CI +- The `moonshot.ts` rewrite to use `OpenAICompatibleHandler` (the AI SDK based class) was a good idea but had a naming error. If this refactor is desired, it should be done in a separate PR with correct imports (`OpenAICompatibleHandler` not `OpenAiCompatibleHandler`) + +## Affected File List +- `src/api/providers/moonshot.ts` - Restored to extend OpenAiHandler, added totalCost to processUsageMetrics +- `src/api/providers/__tests__/anthropic-vertex.spec.ts` - Updated 2 toEqual assertions to include totalCost +- `src/api/providers/__tests__/openai-usage-tracking.spec.ts` - Updated 2 toEqual assertions to include totalCost +- `src/api/providers/__tests__/kenari.spec.ts` - Updated 3 toContainEqual/toEqual assertions to include totalCost 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..c4cbd4454c 100644 --- a/packages/types/package.json +++ b/packages/types/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", "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/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..257a30f3e7 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -122,6 +122,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..3898c65bcc 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -2,6 +2,12 @@ import { z } from "zod" import { modelInfoSchema, 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, @@ -38,16 +44,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 +69,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 +82,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 +95,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 +108,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 +118,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 +131,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) @@ -334,6 +310,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 +434,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 +473,7 @@ export const providerSettingsSchema = z.object({ ...deepSeekSchema.shape, ...poeSchema.shape, ...moonshotSchema.shape, + ...kimiCodeSchema.shape, ...minimaxSchema.shape, ...mimoSchema.shape, ...requestySchema.shape, @@ -569,6 +555,7 @@ export const modelIdKeysByProvider: Record = { "gemini-cli": "apiModelId", mistral: "apiModelId", moonshot: "apiModelId", + "kimi-code": "apiModelId", minimax: "apiModelId", mimo: "apiModelId", deepseek: "apiModelId", @@ -677,6 +664,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..f5f7e71116 100644 --- a/packages/types/src/providers/anthropic.ts +++ b/packages/types/src/providers/anthropic.ts @@ -145,6 +145,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/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/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..0a9c34b979 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -213,6 +213,117 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.start"]) }) + 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 = { + emit: vi.fn(), + getCurrentTask: vi.fn(() => makeParentTask()), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn() }), + 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() } + 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 = { + 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 started, and must be cleaned up + expect(child.start).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() @@ -260,8 +371,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ).rejects.toThrow(persistError) expect(childStart).not.toHaveBeenCalled() - expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { skipDelegationRepair: true }) - expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { skipDelegationRepair: true }) + 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..0cb9425680 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -1,19 +1,44 @@ // 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 { 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" } @@ -39,9 +64,17 @@ describe("Single-open-task invariant", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const addClineToStack = 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, + 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,7 +100,7 @@ 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) @@ -78,10 +111,12 @@ 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, setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, @@ -107,7 +142,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) @@ -120,6 +155,11 @@ describe("Single-open-task invariant", () => { 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, @@ -162,7 +202,7 @@ 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) @@ -172,14 +212,20 @@ describe("Single-open-task invariant", () => { 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 +233,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/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 6e25f62eaf..fc43feae76 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -348,4 +348,22 @@ 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"]() + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index db50edcee1..36b7c5d153 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 diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index b86e73dac6..3617c3cd6d 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -9,21 +9,147 @@ vitest.mock("vscode", () => ({ }, })) -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..16dff14089 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -217,6 +217,7 @@ describe("VertexHandler", () => { type: "usage", inputTokens: 10, outputTokens: 0, + totalCost: expect.any(Number), }) expect(chunks[1]).toEqual({ type: "text", @@ -431,6 +432,7 @@ describe("VertexHandler", () => { outputTokens: 0, cacheWriteTokens: 3, cacheReadTokens: 2, + totalCost: expect.any(Number), }) expect(usageChunks[1]).toEqual({ type: "usage", @@ -1085,6 +1087,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 +1411,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..71c2d1cd0c 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({ @@ -46,10 +62,18 @@ import { } 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 +142,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 +868,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", () => { @@ -1487,6 +1631,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 +1794,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 +1804,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__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index 28691437a2..403f21f56c 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -141,6 +141,7 @@ describe("KenariHandler", () => { inputTokens: 12, outputTokens: 7, cacheReadTokens: 4, + totalCost: 0, }) }) @@ -210,6 +211,7 @@ describe("KenariHandler", () => { inputTokens: 3, outputTokens: 2, cacheReadTokens: undefined, + totalCost: 0, }, ]) }) @@ -318,6 +320,7 @@ describe("KenariHandler", () => { inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined, + totalCost: 0, }) }) 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__/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..c4249f4183 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -1,9 +1,39 @@ // 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 { 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", @@ -145,3 +175,373 @@ describe("OpenAiCodexHandler.createMessage", () => { }) }) }) + +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-compatible.spec.ts b/src/api/providers/__tests__/openai-compatible.spec.ts new file mode 100644 index 0000000000..d60aeb122c --- /dev/null +++ b/src/api/providers/__tests__/openai-compatible.spec.ts @@ -0,0 +1,170 @@ +// npx vitest run api/providers/__tests__/openai-compatible.spec.ts + +import type { ModelInfo } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../../shared/api" + +// Mock @ai-sdk/openai-compatible +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn().mockReturnValue(vi.fn()), +})) + +// Mock ai +vi.mock("ai", () => ({ + streamText: vi.fn(), + generateText: vi.fn(), +})) + +vi.mock("../utils/timeout-config", () => ({ + getApiRequestTimeout: vi.fn().mockReturnValue(300_000), +})) + +import { OpenAICompatibleHandler } from "../openai-compatible" +import type { OpenAICompatibleConfig } from "../openai-compatible" + +/** + * Concrete subclass that exposes the protected processUsageMetrics for direct testing. + */ +class TestableOpenAICompatibleHandler extends OpenAICompatibleHandler { + private _modelInfo: ModelInfo + + constructor(modelInfo: ModelInfo) { + const options: ApiHandlerOptions = { + openAiApiKey: "test-api-key", + } + const config: OpenAICompatibleConfig = { + providerName: "test-provider", + baseURL: "https://test.api.com/v1", + apiKey: "test-api-key", + modelId: "test-model", + modelInfo, + } + super(options, config) + this._modelInfo = modelInfo + } + + override getModel() { + return { + id: this.config.modelId, + info: this._modelInfo, + } + } + + /** Expose protected processUsageMetrics for testing. */ + public testProcessUsageMetrics( + usage: Parameters[0], + ) { + return this.processUsageMetrics(usage) + } + + // Stubs for abstract methods inherited from BaseProvider + override async *createMessage() { + yield { type: "text" as const, text: "" } + } + + override async completePrompt() { + return "" + } +} + +describe("OpenAICompatibleHandler", () => { + describe("processUsageMetrics", () => { + it("should return correct totalCost when modelInfo has pricing", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + inputPrice: 3.0, + outputPrice: 15.0, + } + const handler = new TestableOpenAICompatibleHandler(modelInfo) + + const result = handler.testProcessUsageMetrics({ + inputTokens: 1000, + outputTokens: 500, + }) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(1000) + expect(result.outputTokens).toBe(500) + expect(typeof result.totalCost).toBe("number") + expect(result.totalCost).toBeGreaterThan(0) + }) + + it("should return totalCost of 0 when modelInfo has no pricing", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + } + const handler = new TestableOpenAICompatibleHandler(modelInfo) + + const result = handler.testProcessUsageMetrics({ + inputTokens: 1000, + outputTokens: 500, + }) + + expect(result.type).toBe("usage") + expect(result.totalCost).toBe(0) + }) + + it("should factor cachedInputTokens into cost calculation", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheReadsPrice: 0.3, + } + const handler = new TestableOpenAICompatibleHandler(modelInfo) + + const result = handler.testProcessUsageMetrics({ + inputTokens: 1000, + outputTokens: 500, + details: { + cachedInputTokens: 200, + }, + }) + + expect(result.type).toBe("usage") + expect(result.cacheReadTokens).toBe(200) + expect(typeof result.totalCost).toBe("number") + expect(result.totalCost).toBeGreaterThan(0) + }) + + it("should include reasoningTokens when provided in usage details", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + inputPrice: 3.0, + outputPrice: 15.0, + } + const handler = new TestableOpenAICompatibleHandler(modelInfo) + + const result = handler.testProcessUsageMetrics({ + inputTokens: 500, + outputTokens: 300, + details: { + reasoningTokens: 100, + }, + }) + + expect(result.reasoningTokens).toBe(100) + expect(result.inputTokens).toBe(500) + expect(result.outputTokens).toBe(300) + }) + + it("should default to 0 for missing token counts", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + inputPrice: 3.0, + outputPrice: 15.0, + } + const handler = new TestableOpenAICompatibleHandler(modelInfo) + + const result = handler.testProcessUsageMetrics({}) + + expect(result.inputTokens).toBe(0) + expect(result.outputTokens).toBe(0) + expect(result.totalCost).toBe(0) + }) + }) +}) diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index ddb58ba1cc..fa13181837 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -138,8 +138,9 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalCost: 0, }) - + // Check the usage chunk is the last one reported from the API const lastChunk = chunks[chunks.length - 1] expect(lastChunk.type).toBe("usage") @@ -198,6 +199,7 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalCost: 0, }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 332fc39602..2ec9ef6165 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -6,6 +6,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" import { Package } from "../../../shared/package" +import { ApiStreamChunk } from "../../transform/stream" import axios from "axios" vitest.mock("../utils/timeout-config", () => ({ @@ -149,7 +150,7 @@ describe("OpenAiHandler", () => { }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -199,7 +200,7 @@ describe("OpenAiHandler", () => { }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -216,7 +217,7 @@ describe("OpenAiHandler", () => { it("should handle streaming responses", async () => { const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -239,7 +240,7 @@ describe("OpenAiHandler", () => { }, })) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } @@ -258,7 +259,7 @@ describe("OpenAiHandler", () => { }, })) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } @@ -287,7 +288,7 @@ describe("OpenAiHandler", () => { }, })) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of handler.createMessage(systemPrompt, messages)) { chunks.push(chunk) @@ -343,7 +344,7 @@ describe("OpenAiHandler", () => { }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -414,7 +415,7 @@ describe("OpenAiHandler", () => { }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -652,7 +653,7 @@ describe("OpenAiHandler", () => { })) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -676,7 +677,7 @@ describe("OpenAiHandler", () => { })) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -713,7 +714,7 @@ describe("OpenAiHandler", () => { })) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -755,7 +756,7 @@ describe("OpenAiHandler", () => { })) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -793,7 +794,7 @@ describe("OpenAiHandler", () => { })) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -824,7 +825,7 @@ describe("OpenAiHandler", () => { { role: "assistant", content: [ - { type: "reasoning", text: "I should use the read_file tool.", summary: [] } as any, + { type: "reasoning", text: "I should use the read_file tool.", summary: [] } as unknown as Anthropic.ContentBlock, { type: "tool_use", id: "call_001", name: "read_file", input: { path: "README.md" } }, ], }, @@ -839,8 +840,11 @@ describe("OpenAiHandler", () => { } expect(mockCreate).toHaveBeenCalled() - const sentMessages: any[] = mockCreate.mock.calls[0][0].messages - const assistantMsg = sentMessages.find((m: any) => m.role === "assistant" && m.tool_calls?.length) + const sentMessages: Array<{ role: string; tool_calls?: unknown[]; reasoning_content?: string }> = + mockCreate.mock.calls[0][0].messages + const assistantMsg = sentMessages.find( + (m) => m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0, + ) expect(assistantMsg).toBeDefined() expect(assistantMsg.reasoning_content).toBe("I should use the read_file tool.") }) @@ -874,7 +878,7 @@ describe("OpenAiHandler", () => { it("should handle rate limiting", async () => { const rateLimitError = new Error("Rate limit exceeded") rateLimitError.name = "Error" - ;(rateLimitError as any).status = 429 + ;(rateLimitError as { status: number }).status = 429 mockCreate.mockRejectedValueOnce(rateLimitError) const stream = handler.createMessage("system prompt", testMessages) @@ -905,6 +909,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: "" } }], @@ -959,7 +969,7 @@ describe("OpenAiHandler", () => { ] const stream = azureHandler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1006,7 +1016,7 @@ describe("OpenAiHandler", () => { ] const stream = azureHandler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1127,7 +1137,7 @@ describe("OpenAiHandler", () => { ] const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1190,7 +1200,7 @@ describe("OpenAiHandler", () => { }) const stream = o3Handler.createMessage("system", []) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1254,7 +1264,7 @@ describe("OpenAiHandler", () => { }) const stream = o3Handler.createMessage("system", []) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1286,7 +1296,7 @@ describe("OpenAiHandler", () => { ] const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1330,7 +1340,7 @@ describe("OpenAiHandler", () => { ] const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1392,7 +1402,7 @@ describe("OpenAiHandler", () => { }) const stream = o3Handler.createMessage("system", []) - const chunks: any[] = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) } @@ -1486,6 +1496,122 @@ describe("OpenAiHandler", () => { ) }) }) + + describe("processUsageMetrics totalCost", () => { + it("should include totalCost in the usage chunk from streaming response", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hello" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + } + }, + })) + + const stream = handler.createMessage("system", []) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(typeof usageChunk?.totalCost).toBe("number") + }) + + it("should return totalCost as 0 when model has no pricing", async () => { + const noPricingHandler = new OpenAiHandler({ + openAiApiKey: "test-api-key", + openAiModelId: "custom-model", + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + // No inputPrice or outputPrice set + }, + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hello" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 1000, + completion_tokens: 500, + total_tokens: 1500, + }, + } + }, + })) + + const stream = noPricingHandler.createMessage("system", []) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.totalCost).toBe(0) + }) + + it("should factor cache tokens into totalCost calculation", async () => { + const cacheHandler = new OpenAiHandler({ + openAiApiKey: "test-api-key", + openAiModelId: "gpt-4", + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheReadsPrice: 0.3, + }, + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hello" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 1000, + completion_tokens: 500, + total_tokens: 1500, + cache_creation_input_tokens: 100, + cache_read_input_tokens: 200, + }, + } + }, + })) + + const stream = cacheHandler.createMessage("system", []) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(typeof usageChunk?.totalCost).toBe("number") + expect(usageChunk?.totalCost).toBeGreaterThan(0) + expect(usageChunk?.cacheReadTokens).toBe(200) + expect(usageChunk?.cacheWriteTokens).toBe(100) + }) + }) }) describe("getOpenAiModels", () => { 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/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 9d91b25fe5..84b221687e 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -12,6 +12,7 @@ import { } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostAnthropic } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { addCacheBreakpoints } from "../transform/caching/vertex" @@ -120,18 +121,31 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple for await (const chunk of stream) { switch (chunk.type) { case "message_start": { - const usage = chunk.message!.usage - - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, + const usage = chunk.message!.usage + + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheWriteTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // Compute cost using user-configured pricing from model info. + // Anthropic semantics: inputTokens does NOT include cached tokens. + const modelInfo = this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostAnthropic(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + + yield { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, + } + + break } - - break - } case "message_delta": { yield { type: "usage", 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..5db0d305d3 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, @@ -38,12 +41,14 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import { logger } from "../../utils/logging" import { Package } from "../../shared/package" +import { calculateApiCostAnthropic } from "../../shared/cost" import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy" import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types" import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format" 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" /************************************************************************************ @@ -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") || @@ -575,14 +600,24 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Check both field naming conventions for cache tokens const cacheReadTokens = usage.cacheReadInputTokens || usage.cacheReadInputTokenCount || 0 const cacheWriteTokens = usage.cacheWriteInputTokens || usage.cacheWriteInputTokenCount || 0 + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + + // Compute cost using user-configured pricing from model info. + // Bedrock's inputTokens does NOT include cached tokens (Anthropic semantics). + const costModelInfo = this.costModelConfig?.info + const { totalCost } = costModelInfo + ? calculateApiCostAnthropic(costModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } // Always include all available token information yield { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalCost, } continue } @@ -612,13 +647,22 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH routerUsage.cacheReadTokens || routerUsage.cacheReadInputTokenCount || 0 const cacheWriteTokens = routerUsage.cacheWriteTokens || routerUsage.cacheWriteInputTokenCount || 0 + const inputTokens = routerUsage.inputTokens || 0 + const outputTokens = routerUsage.outputTokens || 0 + + // Compute cost using user-configured pricing from model info. + const costModelInfo = this.costModelConfig?.info + const { totalCost } = costModelInfo + ? calculateApiCostAnthropic(costModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } yield { type: "usage", - inputTokens: routerUsage.inputTokens || 0, - outputTokens: routerUsage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalCost, } } } catch (error) { diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index ef7839ad34..cbbe052816 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -10,6 +10,7 @@ import { } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -186,12 +187,23 @@ export class DeepSeekHandler extends OpenAiHandler { // Override to handle DeepSeek's usage metrics, including caching. protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk { + const inputTokens = usage?.prompt_tokens || 0 + const outputTokens = usage?.completion_tokens || 0 + const cacheWriteTokens = usage?.prompt_tokens_details?.cache_miss_tokens || 0 + const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0 + + const modelInfo = _modelInfo ?? this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens, - cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens, + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } } 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/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/kenari.ts b/src/api/providers/kenari.ts index 7895a9452c..728b405083 100644 --- a/src/api/providers/kenari.ts +++ b/src/api/providers/kenari.ts @@ -9,6 +9,7 @@ import { } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -106,11 +107,22 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan } if (chunk.usage) { + const inputTokens = chunk.usage.prompt_tokens || 0 + const outputTokens = chunk.usage.completion_tokens || 0 + const cacheReadTokens = chunk.usage.prompt_tokens_details?.cached_tokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || undefined, + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } } 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/mistral.ts b/src/api/providers/mistral.ts index c7816feaa2..6c623a9cbe 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,6 +12,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToMistralMessages } from "../transform/mistral-format" import { ApiStream } from "../transform/stream" @@ -155,10 +156,20 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand } if (event.data.usage) { + const inputTokens = event.data.usage.promptTokens || 0 + const outputTokens = event.data.usage.completionTokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, 0) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: event.data.usage.promptTokens || 0, - outputTokens: event.data.usage.completionTokens || 0, + inputTokens, + outputTokens, + totalCost, } } } diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 3e90e48f7a..556dce3258 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -1,34 +1,54 @@ +import OpenAI from "openai" + import { moonshotModels, moonshotDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" 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 +63,23 @@ 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 { + const inputTokens = usage?.prompt_tokens || 0 + const outputTokens = usage?.completion_tokens || 0 + const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? usage?.cached_tokens + + const modelInfo = _modelInfo ?? this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens || 0) + : { totalCost: 0 } return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheWriteTokens: 0, - cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } @@ -68,9 +87,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..27ddbe19e2 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -34,6 +34,68 @@ export type OpenAiCodexModel = ReturnType * 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" + +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 +245,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 +289,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, @@ -344,7 +424,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 +437,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 +475,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 +569,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 +579,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 +1185,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 @@ -1173,7 +1262,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const reasoningEffort = this.getReasoningEffort(model) - const requestBody: any = { + const baseRequestBody: any = { model: model.id, input: [ { @@ -1187,12 +1276,17 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } 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 +1294,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-compatible.ts b/src/api/providers/openai-compatible.ts index 308652804a..37e3be60ea 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -11,6 +11,7 @@ import { streamText, generateText, LanguageModel, ToolSet } from "ai" import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" @@ -94,12 +95,22 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si } raw?: Record }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = usage.details?.cachedInputTokens || 0 + + const modelInfo = this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens: usage.details?.cachedInputTokens, reasoningTokens: usage.details?.reasoningTokens, + totalCost, } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index ca0305aab7..20a68d2884 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -11,6 +11,7 @@ import { } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { TagMatcher } from "../../utils/tag-matcher" @@ -274,12 +275,23 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk { + const inputTokens = usage?.prompt_tokens || 0 + const outputTokens = usage?.completion_tokens || 0 + const cacheWriteTokens = usage?.cache_creation_input_tokens || 0 + const cacheReadTokens = usage?.cache_read_input_tokens || 0 + + const modelInfo = _modelInfo ?? this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.cache_creation_input_tokens || undefined, - cacheReadTokens: usage?.cache_read_input_tokens || undefined, + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } @@ -323,7 +335,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/poe.ts b/src/api/providers/poe.ts index 1e5315b1ba..b12ea89dcc 100644 --- a/src/api/providers/poe.ts +++ b/src/api/providers/poe.ts @@ -13,6 +13,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { shouldUseReasoningBudget, shouldUseReasoningEffort, type ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" @@ -122,6 +123,19 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler const usage = await result.usage if (usage) { const metrics = extractUsageMetrics(usage as any) + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI( + modelInfo, + metrics.inputTokens, + metrics.outputTokens, + metrics.cacheWriteTokens || 0, + metrics.cacheReadTokens || 0, + ) + : { totalCost: 0 } + yield { type: "usage" as const, inputTokens: metrics.inputTokens, @@ -129,6 +143,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler cacheReadTokens: metrics.cacheReadTokens, cacheWriteTokens: metrics.cacheWriteTokens, reasoningTokens: metrics.reasoningTokens, + totalCost, } } } catch (error) { diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 5001b4c8ed..38ad286834 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -7,6 +7,7 @@ import * as path from "path" import { type ModelInfo, type QwenCodeModelId, qwenCodeModels, qwenCodeDefaultModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" @@ -312,10 +313,20 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } if (apiChunk.usage) { + const inputTokens = apiChunk.usage.prompt_tokens || 0 + const outputTokens = apiChunk.usage.completion_tokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = model.info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, 0) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: apiChunk.usage.prompt_tokens || 0, - outputTokens: apiChunk.usage.completion_tokens || 0, + inputTokens, + outputTokens, + totalCost, } } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index c47e9cefd1..74ee30750c 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -5,6 +5,7 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { convertToResponsesApiInput } from "../transform/responses-api-input" @@ -140,7 +141,11 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler throw handleOpenAIError(error, this.providerName) } - const normalizeUsage = createUsageNormalizer() + const modelInfo = model.info + const normalizeUsage = createUsageNormalizer((inputTokens, outputTokens, cacheReadTokens) => { + const { totalCost } = calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + return totalCost + }) yield* processResponsesApiStream(stream, normalizeUsage) } 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/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/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..da593f0553 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" @@ -4220,7 +4221,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/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0c784821c1..0b4201d135 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", () => ({ @@ -161,7 +188,7 @@ vi.mock("../../environment/getEnvironmentDetails", () => ({ vi.mock("../../ignore/RooIgnoreController") vi.mock("../../condense", async (importOriginal) => { - const actual = (await importOriginal()) as any + const actual = await importOriginal() return { ...actual, summarizeConversation: vi.fn().mockResolvedValue({ @@ -274,11 +301,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 } @@ -409,7 +436,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 +464,7 @@ describe("Cline", () => { ts: Date.now(), extraProp: "should be removed", }, - ] as any + ] as Array const iterator = cline.attemptApiRequest(0) await iterator.next() @@ -480,7 +507,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 +530,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 +564,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 +609,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 +703,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 +777,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 +946,7 @@ describe("Cline", () => { beforeEach(() => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", rateLimitSeconds: 5, } @@ -966,7 +993,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 +1031,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 +1086,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 +1126,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 +1152,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 +1187,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 +1212,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 +1242,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 +1277,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 +1300,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 +1339,7 @@ describe("Cline", () => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -1366,7 +1393,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 +1407,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 +1420,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 +1442,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 +1471,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 +1656,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 +1811,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 +1831,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 +1848,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 +1867,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 +1914,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 +1927,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 +2004,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 +2065,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 +2088,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 +2135,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 +2156,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 +2200,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] expect(task.currentRequestAbortController).toBeUndefined() @@ -2147,7 +2221,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 +2265,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 +2284,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 +2320,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 +2358,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 +2399,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] let firstCall = true const retryStream = { @@ -2393,7 +2467,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 +2480,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 +2523,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 +2545,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 +2606,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "startTask").mockImplementation(async () => { + vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => { throw boom }) @@ -2556,7 +2636,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() task.abort = true - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2579,7 +2659,7 @@ describe("Cline", () => { }) expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2610,7 +2690,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 +2720,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2657,9 +2737,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 +2771,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 +2812,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 +2847,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 +2915,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 +2929,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 +2944,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 +2979,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 +3015,7 @@ describe("pushToolResultToUserContent", () => { beforeEach(() => { mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } @@ -2965,7 +3058,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/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 47e31d7e2d..458120d9a4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -50,8 +50,10 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" +import { TaskRegistry } from "../task/TaskRegistry" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" @@ -164,17 +166,9 @@ 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 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 +207,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 @@ -288,7 +282,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 +456,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 +480,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 +497,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 +532,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 +683,13 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") - // Clear all tasks from the stack. - while (this.clineStack.length > 0) { + // 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() } @@ -970,7 +1008,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 +1059,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. @@ -1153,29 +1191,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 @@ -1831,14 +1869,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 +2034,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`) } @@ -2526,6 +2558,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, @@ -2978,11 +3026,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 +3038,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 +3151,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 +3171,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, @@ -3162,21 +3204,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 +3344,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 +3560,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 +3606,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 +3666,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( @@ -3818,7 +3877,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 +3964,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..93f4f2afaa 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" @@ -15,6 +16,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -28,6 +30,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" 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") @@ -257,6 +260,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(), @@ -520,6 +528,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. @@ -1457,7 +1491,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] @@ -2703,8 +2737,10 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2755,8 +2791,10 @@ describe("ClineProvider - Router Models", () => { litellm: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "kimi-code": {}, }, values: undefined, }) @@ -2853,8 +2891,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/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 01abc5db98..3126d4ebf5 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" @@ -833,6 +834,15 @@ export const webviewMessageHandler = async ( case "deleteTaskWithId": 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 @@ -963,7 +973,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 +1051,10 @@ export const webviewMessageHandler = async ( lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": {}, kenari: {}, + "kimi-code": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1136,6 +1148,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 +1197,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) @@ -1221,23 +1264,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}`) + 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. + 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}`) + provider.postMessageToWebview({ + type: "ollamaModels", + ollamaModels: {}, + error: errorMsg, + }) } break } @@ -2601,6 +2669,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 +2774,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 } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json new file mode 100644 index 0000000000..20dd5457da --- /dev/null +++ b/src/eslint-suppressions.json @@ -0,0 +1,1802 @@ +{ + "__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__/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__/vscode-lm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "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/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/vscode-lm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "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__/vscode-lm-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "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/vscode-lm-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "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": 8 + } + }, + "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 + } + } +} \ No newline at end of file diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index 5c866ef941..cea212fefa 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", }, 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/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..5c32e567e2 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -86,4 +86,68 @@ 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("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("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..9ce55a9ac3 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -1,6 +1,14 @@ import { SECRET_STATE_KEYS, GLOBAL_SECRET_KEYS, 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 } @@ -10,6 +18,16 @@ export function checkExistKey(config: ProviderSettings | undefined) { return true } + if (config.apiProvider === "kimi-code" && (config.kimiCodeAuthMethod ?? "oauth") === "oauth") { + return true + } + + // Zoo Gateway uses session auth (profile token and/or global Zoo Code login), + // not a traditional API key listed in SECRET_STATE_KEYS. + if (config.apiProvider === "zoo-gateway") { + 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/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..8b236781b2 --- /dev/null +++ b/webview-ui/playwright-ct.config.ts @@ -0,0 +1,95 @@ +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: { + "@": path.resolve(dirname, "./src"), + "@src": path.resolve(dirname, "./src"), + "@roo": path.resolve(dirname, "../src/shared"), + vscode: path.resolve(dirname, "./src/__mocks__/vscode.ts"), + }, + }, + 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/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/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8687c8e3fa..0941a22e2b 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,6 +1,14 @@ import { memo, useRef, useState, useMemo } from "react" import { useTranslation } from "react-i18next" -import { ChevronUp, ChevronDown, HardDriveDownload, HardDriveUpload, ListChevronsDownUp, ArrowLeft } from "lucide-react" +import { + ChevronUp, + ChevronDown, + HardDriveDownload, + HardDriveUpload, + ListChevronsDownUp, + ArrowLeft, + ArrowRight, +} from "lucide-react" import prettyBytes from "pretty-bytes" import type { ClineMessage } from "@roo-code/types" @@ -15,7 +23,6 @@ import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import { vscode } from "@src/utils/vscode" import Thumbnails from "../common/Thumbnails" - import { TaskActions } from "./TaskActions" import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" @@ -99,6 +106,15 @@ const TaskHeader = ({ } } + // Is this task itself a delegated parent still waiting on a subtask? See #559. + const awaitingChildId = currentTaskItem?.status === "delegated" ? currentTaskItem.awaitingChildId : undefined + + const handleGoToSubtask = () => { + 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__/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/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__/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/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")} +
+ )} ({ + 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/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..9433bb8148 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -20,6 +20,8 @@ import { openRouterDefaultModelId, vscodeLlmModels, vscodeLlmDefaultModelId, + moonshotDefaultModelId, + moonshotModels, } from "@roo-code/types" import { useSelectedModel } from "../useSelectedModel" @@ -1041,4 +1043,99 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(friendliModels["zai-org/GLM-5.1"]) }) }) + + 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: "moonshot", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe("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: "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: "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: "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..50886d4538 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, @@ -103,7 +104,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, @@ -253,9 +259,19 @@ function getSelectedModel({ return { id, info: routerInfo ?? staticInfo } } case "moonshot": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] - return { id, info } + 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 "kimi-code": { + const configuredId = apiConfiguration.apiModelId + const availableModels = routerModels["kimi-code"] + const id = configuredId || defaultModelId + return { id, info: availableModels?.[id] ?? kimiCodeDefaultModelInfo } } case "minimax": { const id = apiConfiguration.apiModelId ?? defaultModelId 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..6f5e11eaf9 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..d4e5e2524e 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", @@ -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..f861b8734c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index a9d3472229..fb63e5b6a1 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", @@ -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/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8566410041..e1ff092237 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -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", 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..37d33f8172 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", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 9111e34430..1a297de709 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", @@ -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..4a5ce59d79 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 3238fcb101..24d27418e7 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", @@ -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..03c64cc1e9 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index a1a0ed56a2..65306b39e8 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": "अवपिन करें", @@ -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..2f6c6fc542 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -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 प्रवेश बिंदु", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f0e73462e9..fb11881cb4 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" @@ -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..f98af2bc28 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 2056e8d977..d6917d1be0 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", @@ -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..ea66a2ad23 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 88aa788626..17665a3754 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": "ピン留め", @@ -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": "クラウドの新機能:", 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/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index dd07efdd9a..eed8315620 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -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 エントリーポイント", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index d826bf7ca8..53186e7afe 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": "고정하기", @@ -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": "클라우드의 새로운 기능:", 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/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 36a4ee14e0..ae8fd3b9ef 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -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 엔트리포인트", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index ad489050e7..356b6f618b 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", @@ -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..45b92345d9 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 46da82306c..9cf15a939d 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", @@ -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..bf35f445dd 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 83452930d6..771d21cd86 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", @@ -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..99d6013a89 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 4e09e1e704..0e1f45ff17 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": "Закрепить", @@ -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": "Новое в облаке:", 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..a29da12326 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 88f6c25968..2e9b107008 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", @@ -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..13c0b9c37c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -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ı", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index f506962395..7737ea7b75 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", @@ -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..b59313d47a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 286e49c8f7..3322c53542 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": "置顶", @@ -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": "云端新功能:", 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/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 03fc075da9..1b408875d4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -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 服务站点", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 6b6aeba6fe..3807cd2489 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": "釘選", @@ -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..481bab5301 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 服務端點", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 04484397c6..8aebe3f8e5 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -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..0a812e9d48 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -127,6 +127,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.qwenCodeOauthPath") } break + case "kimi-code": + if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "vercel-ai-gateway": if (!apiConfiguration.vercelAiGatewayApiKey) { return i18next.t("settings:validation.apiKey") 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",