From 3df406e17564154bce1ecb54a418a96b1202f4f5 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <263205322+roomote[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 21:10:03 -0400 Subject: [PATCH 1/9] [Chore] Unskip VS Code e2e replay for subtasks (#94) * test: unskip subtasks e2e suite * test: tighten subtasks cancellation replay * test(e2e): validation and bumping codecov * fix: prevent child delegation start after parent metadata failure * chore: address delegation review cleanup * fix: delegated subtask lifecycle races * fix: harden delegated subtask cancellation * test(e2e): simplyfying e2e tests --------- Co-authored-by: Roomote Co-authored-by: Elliott de Launay --- apps/vscode-e2e/src/fixtures/subtasks.ts | 95 ++++ apps/vscode-e2e/src/runTest.ts | 2 + apps/vscode-e2e/src/suite/subtasks.test.ts | 226 ++++++-- .../src/suite/tools/apply-diff.test.ts | 6 +- .../src/suite/tools/execute-command.test.ts | 6 +- .../src/suite/tools/list-files.test.ts | 12 +- .../src/suite/tools/read-file.test.ts | 12 +- .../src/suite/tools/search-files.test.ts | 12 +- .../src/suite/tools/use-mcp-tool.test.ts | 6 +- .../src/suite/tools/write-to-file.test.ts | 6 +- src/__tests__/helpers/provider-stub.ts | 17 + .../history-resume-delegation.spec.ts | 227 +++++++- .../nested-delegation-resume.spec.ts | 5 +- src/__tests__/provider-delegation.spec.ts | 53 +- .../removeClineFromStack-delegation.spec.ts | 5 +- .../presentAssistantMessage.ts | 2 +- src/core/task/Task.ts | 8 +- src/core/tools/AttemptCompletionTool.ts | 46 +- .../__tests__/attemptCompletionTool.spec.ts | 147 +++++ src/core/webview/ClineProvider.ts | 516 ++++++++++++------ .../ClineProvider.flicker-free-cancel.spec.ts | 193 +++++++ 21 files changed, 1291 insertions(+), 311 deletions(-) create mode 100644 apps/vscode-e2e/src/fixtures/subtasks.ts create mode 100644 src/__tests__/helpers/provider-stub.ts diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts new file mode 100644 index 0000000000..33bb2c9cbf --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -0,0 +1,95 @@ +import { LLMock } from "@copilotkit/aimock" +import type { ChatCompletionRequest } from "@copilotkit/aimock" + +import { toolResultContains } from "./tool-result" + +const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE" +const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE" + +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 requestContains = (req: ChatCompletionRequest, expected: string[]) => { + const rawRequest = JSON.stringify(req) + return expected.every((text) => rawRequest.includes(text)) +} + +const completionAfterAnswer = (followupId: string, completionId: string) => ({ + match: { + predicate: (req: ChatCompletionRequest) => + // Preferred: structured tool-result message carries the followup answer. + toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) || + // Fallback 1: answer present alongside the tool-call ID but not in a role:tool message. + requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) || + // Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context). + requestContains(req, [ + SUBTASK_CHILD_MARKER, + `\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n`, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "9" }), + id: completionId, + }, + ], + }, +}) + +export function addSubtaskFixtures(mock: InstanceType) { + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_PARENT_MARKER), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_CHILD_PROMPT, + }), + id: "call_subtasks_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_CHILD_MARKER), + }, + response: { + toolCalls: [ + { + name: "ask_followup_question", + arguments: JSON.stringify({ + question: "What is the square root of 81?", + follow_up: [{ text: SUBTASK_CHILD_FOLLOWUP_ANSWER }], + }), + id: "call_subtasks_child_followup_001", + }, + ], + }, + }) + + mock.addFixture(completionAfterAnswer("call_subtasks_child_followup_001", "call_subtasks_child_completion_002")) + + mock.addFixture({ + match: { + toolCallId: "call_subtasks_parent_new_task_001", + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Parent task resumed" }), + id: "call_subtasks_parent_completion_003", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index c5ac0fb932..f3afa51ea2 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -10,6 +10,7 @@ import { addExecuteCommandResultFixtures } from "./fixtures/execute-command" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" +import { addSubtaskFixtures } from "./fixtures/subtasks" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" @@ -110,6 +111,7 @@ async function main() { addListFilesResultFixtures(mock) addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) + addSubtaskFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index e3e3457520..51cf24f6a3 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -2,73 +2,191 @@ import * as assert from "assert" import { RooCodeEventName, type ClineMessage } from "@roo-code/types" -import { sleep, waitFor, waitUntilCompleted } from "./utils" +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitFor, waitUntilCompleted } from "./utils" +import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks" -suite.skip("Roo Code Subtasks", () => { - test("Should handle subtask cancellation and resumption correctly", async () => { +suite("Roo Code Subtasks", function () { + setDefaultSuiteTimeout(this) + + // Race mitigation: skipDelegationRepair prevents removeClineFromStack from + // auto-resuming the parent when the child is cancelled (Race 2). + test("parent stays paused after subtask cancellation", async () => { const api = globalThis.api + const asks: Record = {} + const messages: 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) { + messages[taskId] = messages[taskId] || [] + messages[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_PARENT_PROMPT, + }) + + let spawnedTaskId: string | undefined + await waitFor(() => { + const stack = api.getCurrentTaskStack() + const current = stack[stack.length - 1] + if (current && current !== parentTaskId) { + spawnedTaskId = current + return true + } + return false + }) + + await waitFor( + () => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false, + ) + + await api.cancelCurrentTask() + + 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, + ) + + await api.clearCurrentTask() + // The parent task is still in the stack; drain it so it doesn't leak into the next test. + await api.clearCurrentTask() + await waitFor(() => api.getCurrentTaskStack().length === 0) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } + }) + + // Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard + // ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3). + test("cancelled child completes in-place and does not reopen parent", async () => { + const api = globalThis.api + const asks: Record = {} const messages: Record = {} - api.on(RooCodeEventName.Message, ({ taskId, message }) => { + 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) { messages[taskId] = messages[taskId] || [] messages[taskId].push(message) } - }) - - const childPrompt = "You are a calculator. Respond only with numbers. What is the square root of 9?" - - // Start a parent task that will create a subtask. - const parentTaskId = await api.startNewTask({ - configuration: { - mode: "ask", - alwaysAllowModeSwitch: true, - alwaysAllowSubtasks: true, - autoApprovalEnabled: true, - enableCheckpoints: false, - }, - text: - "You are the parent task. " + - `Create a subtask by using the new_task tool with the message '${childPrompt}'.` + - "After creating the subtask, wait for it to complete and then respond 'Parent task resumed'.", - }) - - let spawnedTaskId: string | undefined = undefined - - // Wait for the subtask to be spawned and then cancel it. - api.on(RooCodeEventName.TaskSpawned, (_, childTaskId) => (spawnedTaskId = childTaskId)) - await waitFor(() => !!spawnedTaskId) - await sleep(1_000) // Give the task a chance to start and populate the history. - await api.cancelCurrentTask() - - // Wait a bit to ensure any task resumption would have happened. - await sleep(2_000) - - // The parent task should not have resumed yet, so we shouldn't see - // "Parent task resumed". - assert.ok( - messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") === - undefined, - "Parent task should not have resumed after subtask cancellation", - ) + } + + const findCompletionText = (taskId: string) => + messages[taskId] + ?.filter( + (message) => + message.type === "say" && (message.say === "completion_result" || message.say === "text"), + ) + .map((message) => message.text?.trim()) + .find((text): text is string => !!text) + + const findErrorText = (taskId: string) => + messages[taskId] + ?.filter((message) => message.type === "say" && message.say === "error") + .map((message) => message.text?.trim()) + .find((text): text is string => !!text) + + api.on(RooCodeEventName.Message, messageHandler) + + try { + const parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_PARENT_PROMPT, + }) + + let spawnedTaskId: string | undefined + await waitFor(() => { + const stack = api.getCurrentTaskStack() + const current = stack[stack.length - 1] + if (current && current !== parentTaskId) { + spawnedTaskId = current + return true + } + return false + }) + + await waitFor( + () => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false, + ) + + const cancelledChildTaskId = spawnedTaskId! + await api.cancelCurrentTask() - // Start a new task with the same message as the subtask. - const anotherTaskId = await api.startNewTask({ text: childPrompt }) - await waitUntilCompleted({ api, taskId: anotherTaskId }) + await waitFor(() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId) + await waitFor( + () => + asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? + false, + ) - // Wait a bit to ensure any task resumption would have happened. - await sleep(2_000) + const resumedChildTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER) + return cancelledChildTaskId + }, + }) - // The parent task should still not have resumed. - assert.ok( - messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") === + assert.strictEqual( + resumedChildTaskId, + cancelledChildTaskId, + "Cancelled child task should be resumed in place", + ) + assert.strictEqual( + findErrorText(resumedChildTaskId), undefined, - "Parent task should not have resumed after subtask cancellation", - ) + "Resumed child task should not emit an error", + ) + assert.strictEqual( + findCompletionText(resumedChildTaskId), + "9", + "Resumed child task should complete with `9`", + ) + assert.strictEqual( + api.getCurrentTaskStack().at(-1), + cancelledChildTaskId, + "Cancelled child task should remain the active completed task", + ) + assert.ok( + messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") === + undefined, + "Parent task should not have resumed after the cancelled child completed", + ) - // Clean up - cancel all tasks. - await api.clearCurrentTask() - await waitUntilCompleted({ api, taskId: parentTaskId }) + await api.clearCurrentTask() + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } }) }) diff --git a/apps/vscode-e2e/src/suite/tools/apply-diff.test.ts b/apps/vscode-e2e/src/suite/tools/apply-diff.test.ts index 39488f8144..36bd847416 100644 --- a/apps/vscode-e2e/src/suite/tools/apply-diff.test.ts +++ b/apps/vscode-e2e/src/suite/tools/apply-diff.test.ts @@ -128,7 +128,7 @@ suite("Roo Code apply_diff Tool", function () { suiteTeardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -147,7 +147,7 @@ suite("Roo Code apply_diff Tool", function () { setup(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -164,7 +164,7 @@ suite("Roo Code apply_diff Tool", function () { teardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/execute-command.test.ts b/apps/vscode-e2e/src/suite/tools/execute-command.test.ts index 586bb85c15..db09fba264 100644 --- a/apps/vscode-e2e/src/suite/tools/execute-command.test.ts +++ b/apps/vscode-e2e/src/suite/tools/execute-command.test.ts @@ -43,7 +43,7 @@ suite("Roo Code execute_command Tool", function () { suiteTeardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -62,7 +62,7 @@ suite("Roo Code execute_command Tool", function () { setup(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -74,7 +74,7 @@ suite("Roo Code execute_command Tool", function () { teardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/list-files.test.ts b/apps/vscode-e2e/src/suite/tools/list-files.test.ts index d8afa52989..578f00238c 100644 --- a/apps/vscode-e2e/src/suite/tools/list-files.test.ts +++ b/apps/vscode-e2e/src/suite/tools/list-files.test.ts @@ -129,9 +129,9 @@ This directory contains various files and subdirectories for testing the list_fi // Clean up test files and directories after all tests suiteTeardown(async () => { - // Cancel any running tasks before cleanup + // Clear any running tasks before cleanup try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -149,9 +149,9 @@ This directory contains various files and subdirectories for testing the list_fi // Clean up before each test setup(async () => { - // Cancel any previous task + // Clear any previous task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -162,9 +162,9 @@ This directory contains various files and subdirectories for testing the list_fi // Clean up after each test teardown(async () => { - // Cancel the current task + // Clear the current task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/read-file.test.ts b/apps/vscode-e2e/src/suite/tools/read-file.test.ts index a8b646842e..da644dc2db 100644 --- a/apps/vscode-e2e/src/suite/tools/read-file.test.ts +++ b/apps/vscode-e2e/src/suite/tools/read-file.test.ts @@ -67,9 +67,9 @@ suite("Roo Code read_file Tool", function () { // Clean up temporary directory and files after tests suiteTeardown(async () => { - // Cancel any running tasks before cleanup + // Clear any running tasks before cleanup try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -96,9 +96,9 @@ suite("Roo Code read_file Tool", function () { // Clean up before each test setup(async () => { - // Cancel any previous task + // Clear any previous task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -109,9 +109,9 @@ suite("Roo Code read_file Tool", function () { // Clean up after each test teardown(async () => { - // Cancel the current task + // Clear the current task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/search-files.test.ts b/apps/vscode-e2e/src/suite/tools/search-files.test.ts index 06a3c99151..caa048f30f 100644 --- a/apps/vscode-e2e/src/suite/tools/search-files.test.ts +++ b/apps/vscode-e2e/src/suite/tools/search-files.test.ts @@ -240,9 +240,9 @@ The search should find matches across different file types and provide context f // Clean up after all tests suiteTeardown(async () => { - // Cancel any running tasks before cleanup + // Clear any running tasks before cleanup try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -270,9 +270,9 @@ The search should find matches across different file types and provide context f // Clean up before each test setup(async () => { - // Cancel any previous task + // Clear any previous task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -283,9 +283,9 @@ The search should find matches across different file types and provide context f // Clean up after each test teardown(async () => { - // Cancel the current task + // Clear the current task try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts index c0fda328fd..21e189d7c1 100644 --- a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts +++ b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts @@ -190,7 +190,7 @@ suite("Roo Code use_mcp_tool Tool", function () { suiteTeardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -201,7 +201,7 @@ suite("Roo Code use_mcp_tool Tool", function () { setup(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -212,7 +212,7 @@ suite("Roo Code use_mcp_tool Tool", function () { teardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/apps/vscode-e2e/src/suite/tools/write-to-file.test.ts b/apps/vscode-e2e/src/suite/tools/write-to-file.test.ts index 9c73a94b4e..f72e513e01 100644 --- a/apps/vscode-e2e/src/suite/tools/write-to-file.test.ts +++ b/apps/vscode-e2e/src/suite/tools/write-to-file.test.ts @@ -43,7 +43,7 @@ suite("Roo Code write_to_file Tool", function () { suiteTeardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -62,7 +62,7 @@ suite("Roo Code write_to_file Tool", function () { setup(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } @@ -74,7 +74,7 @@ suite("Roo Code write_to_file Tool", function () { teardown(async () => { try { - await globalThis.api.cancelCurrentTask() + await globalThis.api.clearCurrentTask() } catch { // Task might not be running } diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts new file mode 100644 index 0000000000..d655c2dd6f --- /dev/null +++ b/src/__tests__/helpers/provider-stub.ts @@ -0,0 +1,17 @@ +import { ClineProvider } from "../../core/webview/ClineProvider" + +/** + * Augments a plain stub object with the instance fields and bound methods that + * ClineProvider methods read from `this` (runDelegationTransition, + * delegationTransitionLocks, cancelledDelegationChildIds), so tests can call + * private methods via `(ClineProvider.prototype as any).method.call(stub, …)` + * without instantiating a real ClineProvider. + */ +export function makeProviderStub(stub: T): T { + const s = stub as any + const proto = ClineProvider.prototype as any + s.delegationTransitionLocks ??= new Map() + s.cancelledDelegationChildIds ??= new Set() + s.runDelegationTransition = proto.runDelegationTransition.bind(s) + return s +} diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index a78c41b7c0..6fc0686626 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -38,6 +38,7 @@ vi.mock("../core/task-persistence", () => ({ import { ClineProvider } from "../core/webview/ClineProvider" import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" +import { makeProviderStub } from "./helpers/provider-stub" describe("History resume delegation - parent metadata transitions", () => { beforeEach(() => { @@ -71,7 +72,7 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), }) - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId, emit: providerEmit, @@ -79,7 +80,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, updateTaskHistory, - } as unknown as ClineProvider + } as any) // Mock persistence reads to return empty arrays vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -110,6 +111,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(createTaskWithHistoryItem).toHaveBeenCalledWith( expect.objectContaining({ status: "active", @@ -120,7 +122,7 @@ describe("History resume delegation - parent metadata transitions", () => { }) it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/storage" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -145,7 +147,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) // Start with existing messages in history const existingUiMessages = [{ type: "ask", ask: "tool", text: "Old tool", ts: 50 }] @@ -203,7 +205,7 @@ describe("History resume delegation - parent metadata transitions", () => { }) it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => { - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/storage" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -228,7 +230,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) // Include an assistant message with new_task tool_use to exercise the tool_result path const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }] @@ -289,7 +291,7 @@ describe("History resume delegation - parent metadata transitions", () => { }) it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => { - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/storage" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -314,7 +316,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) // No assistant tool_use in history const existingUiMessages = [{ type: "ask", ask: "tool", text: "subtask request", ts: 50 }] @@ -349,7 +351,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), } - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -369,7 +371,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -389,7 +391,7 @@ describe("History resume delegation - parent metadata transitions", () => { const emitSpy = vi.fn() const updateTaskHistory = vi.fn().mockResolvedValue([]) - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -413,7 +415,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory, - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -455,7 +457,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockRejectedValue(new Error("api overwrite failed")), } - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockImplementation(async (id: string) => { if (id === "parent-rpd06") { @@ -491,7 +493,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -502,7 +504,7 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "child-rpd06", completionResultSummary: "Subtask finished despite overwrite failures", }), - ).resolves.toBeUndefined() + ).resolves.toBe(true) expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1) expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1) @@ -525,7 +527,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => { const emitSpy = vi.fn() - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -549,7 +551,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -578,7 +580,7 @@ describe("History resume delegation - parent metadata transitions", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTaskWithHistoryItem = vi.fn().mockResolvedValue(parentInstance) - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockImplementation(async (id: string) => { if (id === "parent-rpd02") { @@ -613,7 +615,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, updateTaskHistory, - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -658,7 +660,7 @@ describe("History resume delegation - parent metadata transitions", () => { return [] }) - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockImplementation(async (id: string) => { if (id === "parent-rpd04") { @@ -694,7 +696,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), updateTaskHistory, - } as unknown as ClineProvider + } as any) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -705,7 +707,7 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "child-rpd04", completionResultSummary: "Child completion with persistence failure", }), - ).resolves.toBeUndefined() + ).resolves.toBe(true) expect(logSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -723,8 +725,49 @@ describe("History resume delegation - parent metadata transitions", () => { expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd04", "child-rpd04") }) + it("reopenParentFromDelegation keeps the child open when parent metadata persistence fails", async () => { + const persistError = new Error("parent status persist failed") + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-rpd05", + status: "delegated", + awaitingChildId: "child-rpd05", + childIds: ["child-rpd05"], + ts: 800, + task: "Parent RPD-05", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-rpd05" })), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory: vi.fn().mockRejectedValue(persistError), + } as any) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-rpd05", + childTaskId: "child-rpd05", + completionResultSummary: "Child completion", + }), + ).rejects.toThrow(persistError) + + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + }) + it("handles empty history gracefully when injecting synthetic messages", async () => { - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { @@ -748,7 +791,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - } as unknown as ClineProvider + } as any) // Mock read failures or empty returns vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -760,7 +803,7 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "c5", completionResultSummary: "Result", }), - ).resolves.toBeUndefined() + ).resolves.toBe(true) // Verify saves still occurred with just the injected message expect(saveTaskMessages).toHaveBeenCalledWith( @@ -784,4 +827,138 @@ describe("History resume delegation - parent metadata transitions", () => { }), ) }) + + it("reopenParentFromDelegation aborts when parent is already active (stale-delegation guard)", async () => { + const logSpy = vi.fn() + const updateTaskHistory = vi.fn() + const saveTaskMessagesMock = vi.mocked(saveTaskMessages) + const saveApiMessagesMock = vi.mocked(saveApiMessages) + + const makeProvider = (historyItem: object) => + makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem }), + emit: vi.fn(), + log: logSpy, + getCurrentTask: vi.fn(() => null), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + updateTaskHistory, + } as any) + + const providerActive = makeProvider({ + id: "parent-guard", + status: "active", + awaitingChildId: undefined, + }) + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(providerActive, { + parentTaskId: "parent-guard", + childTaskId: "child-guard", + completionResultSummary: "should be ignored", + }), + ).resolves.toBe(false) + expect(saveTaskMessagesMock).not.toHaveBeenCalled() + expect(saveApiMessagesMock).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[reopenParentFromDelegation] Aborting")) + }) + + it("reopenParentFromDelegation aborts when in-process cancellation failed closed", async () => { + const logSpy = vi.fn() + const updateTaskHistory = vi.fn() + const saveTaskMessagesMock = vi.mocked(saveTaskMessages) + const saveApiMessagesMock = vi.mocked(saveApiMessages) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-guard", + status: "delegated", + awaitingChildId: "child-guard", + }, + }), + emit: vi.fn(), + log: logSpy, + getCurrentTask: vi.fn(() => null), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + updateTaskHistory, + cancelledDelegationChildIds: new Set(["child-guard"]), + } as any) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-guard", + childTaskId: "child-guard", + completionResultSummary: "should be ignored", + }), + ).resolves.toBe(false) + + expect(saveTaskMessagesMock).not.toHaveBeenCalled() + expect(saveApiMessagesMock).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[reopenParentFromDelegation] Aborting")) + }) + + it("reopenParentFromDelegation aborts when parent awaits a different child (stale-delegation guard)", async () => { + const logSpy = vi.fn() + const updateTaskHistory = vi.fn() + const saveTaskMessagesMock = vi.mocked(saveTaskMessages) + const saveApiMessagesMock = vi.mocked(saveApiMessages) + + const makeProvider = (historyItem: object) => + makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem }), + emit: vi.fn(), + log: logSpy, + getCurrentTask: vi.fn(() => null), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + updateTaskHistory, + } as any) + + const providerWrongChild = makeProvider({ + id: "parent-guard", + status: "delegated", + awaitingChildId: "other-child", + }) + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(providerWrongChild, { + parentTaskId: "parent-guard", + childTaskId: "child-guard", + completionResultSummary: "should be ignored", + }), + ).resolves.toBe(false) + expect(saveTaskMessagesMock).not.toHaveBeenCalled() + expect(saveApiMessagesMock).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[reopenParentFromDelegation] Aborting")) + }) + + it("serializes delegation transitions and continues after a rejected predecessor", async () => { + const provider = makeProviderStub({} as any) as any + const calls: string[] = [] + let rejectFirst!: (error: Error) => void + + const first = provider.runDelegationTransition("parent-lock", async () => { + calls.push("first") + await new Promise((_resolve, reject) => { + rejectFirst = reject + }) + }) + const second = provider.runDelegationTransition("parent-lock", async () => { + calls.push("second") + return "done" + }) + + await Promise.resolve() + expect(calls).toEqual(["first"]) + + rejectFirst(new Error("first transition failed")) + await expect(first).rejects.toThrow("first transition failed") + await expect(second).resolves.toBe("done") + expect(calls).toEqual(["first", "second"]) + }) }) diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index 5dbafc949c..9f78ba14bb 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { RooCodeEventName } from "@roo-code/types" +import { makeProviderStub } from "./helpers/provider-stub" // Mock safe-stable-stringify to avoid runtime error vi.mock("safe-stable-stringify", () => ({ @@ -148,7 +149,7 @@ describe("Nested delegation resume (A → B → C)", () => { return Object.values(historyIndex) }) - const provider = { + const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId, emit: emitSpy, @@ -160,7 +161,7 @@ describe("Nested delegation resume (A → B → C)", () => { reopenParentFromDelegation: vi.fn(async (params: any) => { return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params) }), - } as unknown as ClineProvider + } as unknown as ClineProvider) // Empty histories for simplicity vi.mocked(readTaskMessages).mockResolvedValue([]) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 4b04fb5bbb..bd4519b970 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -98,7 +98,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { it("calls child.start() only after parent metadata is persisted (no race condition)", async () => { const callOrder: string[] = [] - const parentTask = { taskId: "parent-1", emit: vi.fn() } as any + const parentTask = { + taskId: "parent-1", + emit: vi.fn(), + } as any const childStart = vi.fn(() => callOrder.push("child.start")) const updateTaskHistory = vi.fn(async () => { @@ -142,4 +145,52 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Verify ordering: createTask → updateTaskHistory → child.start expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"]) }) + + it("rolls back the paused child and restores the parent when metadata persistence fails", async () => { + const persistError = new Error("parent metadata persist failed") + const parentHistory = { + id: "parent-1", + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + } + const parentTask = { + taskId: "parent-1", + emit: vi.fn(), + } as any + const childStart = vi.fn() + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + + const provider = { + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }), + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistory }), + updateTaskHistory: vi.fn().mockRejectedValue(persistError), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId, + createTaskWithHistoryItem, + log: vi.fn(), + } as unknown as ClineProvider + + await expect( + (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow(persistError) + + expect(childStart).not.toHaveBeenCalled() + expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { skipDelegationRepair: true }) + expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { skipDelegationRepair: true }) + expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistory) + }) }) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index a72f580d6f..6ed2a5c221 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest" import { ClineProvider } from "../core/webview/ClineProvider" +import { makeProviderStub } from "./helpers/provider-stub" describe("ClineProvider.removeClineFromStack() delegation awareness", () => { /** @@ -32,13 +33,13 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { throw new Error("Task not found") }) - const provider = { + const provider = makeProviderStub({ clineStack: [childTask] as any[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId, updateTaskHistory, - } + }) return { provider, childTask, updateTaskHistory, getTaskWithId } } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..cc675dd948 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -60,7 +60,7 @@ import { sanitizeToolUseId } from "../../utils/tool-id" export async function presentAssistantMessage(cline: Task) { if (cline.abort) { - throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`) + return } if (cline.presentAssistantMessageLocked) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fcdfd0263d..454109778b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3470,7 +3470,13 @@ export class Task extends EventEmitter implements TaskLike { // this.userMessageContentReady = true // } - await pWaitFor(() => this.userMessageContentReady) + await pWaitFor(() => this.userMessageContentReady || this.abort || this.abandoned) + + if (this.abort || this.abandoned) { + throw new Error( + `[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`, + ) + } // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index a70576d75f..3347f63f40 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -30,7 +30,7 @@ interface DelegationProvider { parentTaskId: string childTaskId: string completionResultSummary: string - }): Promise + }): Promise } export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { @@ -86,6 +86,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // to prevent duplicate tool_results when user revisits from history const provider = task.providerRef.deref() as DelegationProvider | undefined if (provider) { + let historyLookupTaskId = task.taskId try { const { historyItem } = await provider.getTaskWithId(task.taskId) const status = historyItem?.status @@ -96,18 +97,28 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // This shows the user the completion result and waits for acceptance // without injecting another tool_result to the parent } else if (status === "active") { - // Normal subtask completion - do delegation - const delegation = await this.delegateToParent( - task, - result, - provider, - askFinishSubTaskApproval, - pushToolResult, - ) - if (delegation === "delegated") { - this.emitTaskCompleted(task) + historyLookupTaskId = task.parentTaskId + const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId) + + if ( + parentHistory?.status === "delegated" && + parentHistory?.awaitingChildId === task.taskId + ) { + const delegation = await this.delegateToParent( + task, + result, + provider, + askFinishSubTaskApproval, + pushToolResult, + ) + if (delegation === "delegated") { + this.emitTaskCompleted(task) + } + if (delegation !== "continue") return + } else { + // Parent already detached, such as when the user cancelled this child. + // Fall through to the normal completion ask flow. } - if (delegation !== "continue") return } else { // Unexpected status (undefined or "delegated") - log error and skip delegation // undefined indicates a bug in status persistence during child creation @@ -121,7 +132,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } catch (err) { // If we can't get the history, log error and skip delegation console.error( - `[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` + + `[AttemptCompletionTool] Failed to get history for task ${historyLookupTaskId}: ${(err as Error)?.message ?? String(err)}. ` + `Skipping delegation.`, ) // Fall through to normal completion ask flow @@ -167,14 +178,17 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { return "denied" } - pushToolResult("") - - await provider.reopenParentFromDelegation({ + const didReopen = await provider.reopenParentFromDelegation({ parentTaskId: task.parentTaskId!, childTaskId: task.taskId, completionResultSummary: result, }) + if (didReopen === false) { + return "continue" + } + + pushToolResult("") return "delegated" } diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index 6c9b9a2ccc..b64b6c57a0 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -484,6 +484,153 @@ describe("attemptCompletionTool", () => { }) describe("completion lifecycle", () => { + it("delegates an active subtask completion only when the parent is awaiting that child", async () => { + const block: AttemptCompletionToolUse = { + type: "tool_use", + name: "attempt_completion", + params: { result: "9" }, + nativeArgs: { result: "9" }, + partial: false, + } + const mockProvider = { + getTaskWithId: vi.fn().mockImplementation((id: string) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: { id, status: "active" } }) + } + if (id === "parent-1") { + return Promise.resolve({ + historyItem: { id, status: "delegated", awaitingChildId: "child-1" }, + }) + } + throw new Error(`unexpected task id ${id}`) + }), + reopenParentFromDelegation: vi.fn().mockResolvedValue(true), + } + + Object.assign(mockTask, { + taskId: "child-1", + parentTaskId: "parent-1", + providerRef: { deref: () => mockProvider }, + }) + mockAskFinishSubTaskApproval.mockResolvedValue(true) + + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockAskFinishSubTaskApproval).toHaveBeenCalled() + expect(mockProvider.reopenParentFromDelegation).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "9", + }) + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("") + }) + + it("falls through to standalone completion when parent delegation becomes stale after approval", async () => { + const block: AttemptCompletionToolUse = { + type: "tool_use", + name: "attempt_completion", + params: { result: "9" }, + nativeArgs: { result: "9" }, + partial: false, + } + const mockProvider = { + getTaskWithId: vi.fn().mockImplementation((id: string) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: { id, status: "active" } }) + } + if (id === "parent-1") { + return Promise.resolve({ + historyItem: { id, status: "delegated", awaitingChildId: "child-1" }, + }) + } + throw new Error(`unexpected task id ${id}`) + }), + reopenParentFromDelegation: vi.fn().mockResolvedValue(false), + } + + Object.assign(mockTask, { + taskId: "child-1", + parentTaskId: "parent-1", + providerRef: { deref: () => mockProvider }, + }) + mockTask.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: "revise", images: [] }) + mockAskFinishSubTaskApproval.mockResolvedValue(true) + + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockProvider.reopenParentFromDelegation).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "9", + }) + expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("") + expect(mockCaptureTaskCompleted).not.toHaveBeenCalled() + }) + + it("does not resume the parent when the parent is no longer awaiting this child", async () => { + const block: AttemptCompletionToolUse = { + type: "tool_use", + name: "attempt_completion", + params: { result: "9" }, + nativeArgs: { result: "9" }, + partial: false, + } + const mockProvider = { + getTaskWithId: vi.fn().mockImplementation((id: string) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: { id, status: "active" } }) + } + if (id === "parent-1") { + return Promise.resolve({ + historyItem: { id, status: "active", awaitingChildId: undefined }, + }) + } + throw new Error(`unexpected task id ${id}`) + }), + reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined), + } + + Object.assign(mockTask, { + taskId: "child-1", + parentTaskId: "parent-1", + providerRef: { deref: () => mockProvider }, + }) + mockAskFinishSubTaskApproval.mockResolvedValue(true) + + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockAskFinishSubTaskApproval).not.toHaveBeenCalled() + expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() + expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) + expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") + }) + it("emits TaskCompleted only when completion is accepted", async () => { const block: AttemptCompletionToolUse = { type: "tool_use", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d5d34d0e91..b213c0862d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -114,6 +114,32 @@ export type ClineProviderEvents = { clineCreated: [cline: Task] } +function runDelegationTransition( + locks: Map>, + parentTaskId: string, + fn: () => Promise, +): Promise { + const previous = locks.get(parentTaskId) ?? Promise.resolve() + // Fail-forward: run fn even if the previous transition rejected. A failed + // cancelTask must not permanently block a subsequent reopenParentFromDelegation. + // The cancelledDelegationChildIds guard inside each fn is the safety net. + const current = previous.then(fn, fn) + const tail = current.then( + () => {}, + () => {}, + ) + + locks.set(parentTaskId, tail) + + tail.finally(() => { + if (locks.get(parentTaskId) === tail) { + locks.delete(parentTaskId) + } + }) + + return current +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -128,6 +154,8 @@ export class ClineProvider private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel private clineStack: Task[] = [] + private delegationTransitionLocks?: Map> + private cancelledDelegationChildIds = new Set() private codeIndexStatusSubscription?: vscode.Disposable private codeIndexManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class @@ -146,6 +174,11 @@ export class ClineProvider private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + + private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { + this.delegationTransitionLocks ??= new Map() + return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn) + } private readonly pendingEditOperations: PendingEditOperationStore private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null @@ -482,18 +515,20 @@ export class ClineProvider // child and will update the parent to point at the new child. if (parentTaskId && childTaskId && !options?.skipDelegationRepair) { try { - const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) - - if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === childTaskId) { - await this.updateTaskHistory({ - ...parentHistory, - status: "active", - awaitingChildId: undefined, - }) - this.log( - `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, - ) - } + await this.runDelegationTransition(parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === childTaskId) { + await this.updateTaskHistory({ + ...parentHistory, + status: "active", + awaitingChildId: undefined, + }) + this.log( + `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, + ) + } + }) } catch (err) { // Non-fatal: log but do not block the pop operation. this.log( @@ -2869,7 +2904,9 @@ export class ClineProvider }) await this.addClineToStack(task) - task.start() + if (options.startTask !== false) { + task.start() + } this.log( `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, @@ -2903,8 +2940,8 @@ export class ClineProvider } // Preserve parent and root task information for history item. - const rootTask = task.rootTask - const parentTask = task.parentTask + let rootTask = task.rootTask + let parentTask = task.parentTask // Mark this as a user-initiated cancellation so provider-only rehydration can occur task.abortReason = "user_cancelled" @@ -2962,6 +2999,57 @@ export class ClineProvider return } + if (task.parentTaskId) { + try { + await this.runDelegationTransition(task.parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!) + + if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { + await this.updateTaskHistory({ + ...parentHistory, + status: "active", + awaitingChildId: undefined, + }) + + this.log( + `[cancelTask] Detached delegated parent ${task.parentTaskId}: delegated → active (child ${task.taskId} cancelled)`, + ) + parentTask = undefined + rootTask = undefined + // Clear any stale fail-closed entry from a prior failed cancel attempt. + this.cancelledDelegationChildIds.delete(task.taskId) + } + }) + } catch (error) { + // Fail closed: if we cannot prove the parent was detached, make the + // rehydrated child standalone so later completions cannot reopen a + // stale delegated parent, even after a provider reload. + parentTask = undefined + rootTask = undefined + this.cancelledDelegationChildIds.add(task.taskId) + historyItem = { + ...historyItem, + parentTaskId: undefined, + rootTaskId: undefined, + } + try { + await this.updateTaskHistory(historyItem) + } catch (historyError) { + this.log( + `[cancelTask] Failed to persist standalone child state for ${task.taskId}: ${ + historyError instanceof Error ? historyError.message : String(historyError) + }`, + ) + throw historyError + } + this.log( + `[cancelTask] Failed to detach delegated parent for ${task.taskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + // Clears task again, so we need to abortTask manually above. await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) } @@ -3244,6 +3332,35 @@ export class ClineProvider (err as Error)?.message ?? String(err) }`, ) + try { + await this.removeClineFromStack({ skipDelegationRepair: true }) + } catch (cleanupError) { + this.log( + `[delegateParentAndOpenChild] Failed to close paused child ${child.taskId} during rollback: ${ + (cleanupError as Error)?.message ?? String(cleanupError) + }`, + ) + } + try { + await this.deleteTaskWithId(child.taskId, false) + } catch (cleanupError) { + this.log( + `[delegateParentAndOpenChild] Failed to delete paused child ${child.taskId} during rollback: ${ + (cleanupError as Error)?.message ?? String(cleanupError) + }`, + ) + } + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + await this.createTaskWithHistoryItem(parentHistory) + } catch (rollbackError) { + this.log( + `[delegateParentAndOpenChild] Failed to restore parent ${parentTaskId} during rollback: ${ + (rollbackError as Error)?.message ?? String(rollbackError) + }`, + ) + } + throw err } // 6) Start the child task now that parent metadata is safely persisted. @@ -3266,196 +3383,237 @@ export class ClineProvider parentTaskId: string childTaskId: string completionResultSummary: string - }): Promise { + }): Promise { const { parentTaskId, childTaskId, completionResultSummary } = params - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - - // 1) Load parent from history and current persisted messages - const { historyItem } = await this.getTaskWithId(parentTaskId) + return this.runDelegationTransition(parentTaskId, async () => { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - let parentClineMessages: ClineMessage[] = [] - try { - parentClineMessages = await readTaskMessages({ - taskId: parentTaskId, - globalStoragePath, - }) - } catch { - parentClineMessages = [] - } + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) - let parentApiMessages: any[] = [] - try { - parentApiMessages = (await readApiMessages({ - taskId: parentTaskId, - globalStoragePath, - })) as any[] - } catch { - parentApiMessages = [] - } - - // 2) Inject synthetic records: UI subtask_result and update API tool_result - const ts = Date.now() - - // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] - - const subtaskUiMessage: ClineMessage = { - type: "say", - say: "subtask_result", - text: completionResultSummary, - ts, - } - parentClineMessages.push(subtaskUiMessage) - await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) - - // Find the tool_use_id from the last assistant message's new_task tool_use - let toolUseId: string | undefined - for (let i = parentApiMessages.length - 1; i >= 0; i--) { - const msg = parentApiMessages[i] - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "new_task") { - toolUseId = block.id - break - } - } - if (toolUseId) break - } - } - - // Preferred: if the parent history contains the native tool_use for new_task, - // inject a matching tool_result for the Anthropic message contract: - // user → assistant (tool_use) → user (tool_result) - if (toolUseId) { - // Check if the last message is already a user message with a tool_result for this tool_use_id - // (in case this is a retry or the history was already updated) - const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false - if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - alreadyHasToolResult = true - break - } - } + // Guard: re-validate delegation state after the async approval gap. + // cancelTask() or removeClineFromStack() may have already detached the parent + // (setting status → "active", awaitingChildId → undefined) while the user was + // approving the subtask finish. If the parent no longer awaits this child, + // routing output back would corrupt an unrelated task. + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + historyItem.status !== "delegated" || + historyItem.awaitingChildId !== childTaskId + ) { + this.log( + `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + + `(status=${historyItem.status}, awaitingChildId=${historyItem.awaitingChildId})`, + ) + return false } - // If no existing tool_result found, create a NEW user message with the tool_result - if (!alreadyHasToolResult) { - parentApiMessages.push({ - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], - ts, + let parentClineMessages: ClineMessage[] = [] + try { + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, }) + } catch { + parentClineMessages = [] } - // Validate the newly injected tool_result against the preceding assistant message. - // This ensures the tool_result's tool_use_id matches a tool_use in the immediately - // preceding assistant message (Anthropic API requirement). - const lastMessage = parentApiMessages[parentApiMessages.length - 1] - if (lastMessage?.role === "user") { - const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) - parentApiMessages[parentApiMessages.length - 1] = validatedMessage + let parentApiMessages: any[] = [] + try { + parentApiMessages = (await readApiMessages({ + taskId: parentTaskId, + globalStoragePath, + })) as any[] + } catch { + parentApiMessages = [] } - } else { - // If there is no corresponding tool_use in the parent API history, we cannot emit a - // tool_result. Fall back to a plain user text note so the parent can still resume. - parentApiMessages.push({ - role: "user", - content: [ - { - type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], + + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() + + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { + type: "say", + say: "subtask_result", + text: completionResultSummary, ts, - }) - } + } + const lastParentClineMessage = parentClineMessages.at(-1) + if ( + lastParentClineMessage?.type !== "say" || + lastParentClineMessage.say !== "subtask_result" || + lastParentClineMessage.text !== completionResultSummary + ) { + parentClineMessages.push(subtaskUiMessage) + } + await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) + + // Find the tool_use_id from the last assistant message's new_task tool_use + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i] + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } + } + if (toolUseId) break + } + } - await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) + // Preferred: if the parent history contains the native tool_use for new_task, + // inject a matching tool_result for the Anthropic message contract: + // user → assistant (tool_use) → user (tool_result) + if (toolUseId) { + // Check if the last message is already a user message with a tool_result for this tool_use_id + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + // Update the existing tool_result content + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } + } + } - // 3) Close child instance if still open (single-open-task invariant). - // This MUST happen BEFORE updating the child's status to "completed" because - // removeClineFromStack() → abortTask(true) → saveClineMessages() writes - // the historyItem with initialStatus (typically "active"), which would - // overwrite a "completed" status set earlier. - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - await this.removeClineFromStack() - } + // If no existing tool_result found, create a NEW user message with the tool_result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: toolUseId, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } - // 4) Update child metadata to "completed" status. - // This runs after the abort so it overwrites the stale "active" status - // that saveClineMessages() may have written during step 3. - try { - const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) - await this.updateTaskHistory({ - ...childHistory, - status: "completed", - }) - } catch (err) { - this.log( - `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ - (err as Error)?.message ?? String(err) - }`, - ) - } + // Validate the newly injected tool_result against the preceding assistant message. + // This ensures the tool_result's tool_use_id matches a tool_use in the immediately + // preceding assistant message (Anthropic API requirement). + const lastMessage = parentApiMessages[parentApiMessages.length - 1] + if (lastMessage?.role === "user") { + const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) + parentApiMessages[parentApiMessages.length - 1] = validatedMessage + } + } else { + // If there is no corresponding tool_use in the parent API history, we cannot emit a + // tool_result. Fall back to a plain user text note so the parent can still resume. + const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + const lastParentApiMessage = parentApiMessages.at(-1) + const alreadyHasFallback = + lastParentApiMessage?.role === "user" && + Array.isArray(lastParentApiMessage.content) && + lastParentApiMessage.content.some( + (block: { type?: string; text?: string }) => + block.type === "text" && block.text === fallbackText, + ) + if (!alreadyHasFallback) { + parentApiMessages.push({ + role: "user", + content: [ + { + type: "text" as const, + text: fallbackText, + }, + ], + ts, + }) + } + } - // 5) Update parent metadata and persist BEFORE emitting completion event - const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) - const updatedHistory: typeof historyItem = { - ...historyItem, - status: "active", - completedByChildId: childTaskId, - completionResultSummary, - awaitingChildId: undefined, - childIds, - } - await this.updateTaskHistory(updatedHistory) + await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) - // 6) Emit TaskDelegationCompleted (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) - } catch { - // non-fatal - } + // 3) Persist parent metadata before closing the child. If persistence fails, + // the delegated child remains active and can retry completion. + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + const updatedHistory: typeof historyItem = { + ...historyItem, + status: "active", + completedByChildId: childTaskId, + completionResultSummary, + awaitingChildId: undefined, + childIds, + } + await this.updateTaskHistory(updatedHistory) - // 7) Reopen the parent from history as the sole active task (restores saved mode) - // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling - const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + // 4) Close child instance if still open (single-open-task invariant). + // This MUST happen BEFORE updating the child's status to "completed" because + // removeClineFromStack() → abortTask(true) → saveClineMessages() writes + // the historyItem with initialStatus (typically "active"), which would + // overwrite a "completed" status set earlier. + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack({ skipDelegationRepair: true }) + } - // 8) Inject restored histories into the in-memory instance before resuming - if (parentInstance) { + // 5) Update child metadata to "completed" status. + // This runs after the abort so it overwrites the stale "active" status + // that saveClineMessages() may have written during step 4. try { - await parentInstance.overwriteClineMessages(parentClineMessages) + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + await this.updateTaskHistory({ + ...childHistory, + status: "completed", + }) + } catch (err) { + this.log( + `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 6) Emit TaskDelegationCompleted (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } + + // 7) Reopen the parent from history as the sole active task (restores saved mode) + // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling + const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + + // 8) Inject restored histories into the in-memory instance before resuming + if (parentInstance) { + try { + await parentInstance.overwriteClineMessages(parentClineMessages) + } catch { + // non-fatal + } + try { + await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + } catch { + // non-fatal + } + + // Auto-resume parent without ask("resume_task") + await parentInstance.resumeAfterDelegation() + } + + // 9) Emit TaskDelegationResumed (provider-level) try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) } catch { // non-fatal } - // Auto-resume parent without ask("resume_task") - await parentInstance.resumeAfterDelegation() - } - - // 9) Emit TaskDelegationResumed (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal - } + this.cancelledDelegationChildIds.delete(childTaskId) + return true + }) } /** 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 4bb01347a3..57c040e661 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -316,4 +316,197 @@ describe("ClineProvider flicker-free cancel", () => { expect((provider as any).clineStack[0]).toBe(mockParentTask) expect((provider as any).clineStack[1]).toBe(mockTask2) }) + + it("detaches runtime parent links for a cancelled delegated child while preserving history lineage", async () => { + const mockRootTask = { taskId: "root-1" } + const mockParentTask = { taskId: "parent-1" } + const childHistory: HistoryItem = { + id: "child-1", + number: 2, + task: "child task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + parentTaskId: "parent-1", + rootTaskId: "root-1", + } + 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", + } + + Object.assign(mockTask1, { + taskId: "child-1", + instanceId: "instance-child", + rootTask: mockRootTask, + parentTask: mockParentTask, + parentTaskId: "parent-1", + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn(), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + ;(provider as any).clineStack = [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 + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + const createTaskWithHistoryItemSpy = vi + .spyOn(provider, "createTaskWithHistoryItem") + .mockResolvedValue(undefined as any) + + await provider.cancelTask() + + expect(updateTaskHistorySpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + }), + ) + expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-1", + parentTaskId: "parent-1", + rootTaskId: "root-1", + parentTask: undefined, + rootTask: undefined, + }), + ) + }) + + it("detaches runtime parent links when delegated parent detach fails", async () => { + const mockRootTask = { taskId: "root-1" } + const mockParentTask = { taskId: "parent-1" } + const childHistory: HistoryItem = { + id: "child-1", + number: 2, + task: "child task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + parentTaskId: "parent-1", + rootTaskId: "root-1", + } + + Object.assign(mockTask1, { + taskId: "child-1", + instanceId: "instance-child", + rootTask: mockRootTask, + parentTask: mockParentTask, + parentTaskId: "parent-1", + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn(), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + ;(provider as any).clineStack = [mockTask1] + provider.getTaskWithId = vi.fn().mockImplementation((id) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: childHistory }) + } + if (id === "parent-1") { + return Promise.reject(new Error("parent lookup failed")) + } + throw new Error(`unexpected task lookup: ${id}`) + }) as any + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + const createTaskWithHistoryItemSpy = vi + .spyOn(provider, "createTaskWithHistoryItem") + .mockResolvedValue(undefined as any) + + await provider.cancelTask() + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[cancelTask] Failed to detach delegated parent for child-1: parent lookup failed"), + ) + expect(updateTaskHistorySpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-1", + parentTaskId: undefined, + rootTaskId: undefined, + }), + ) + expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-1", + parentTaskId: undefined, + rootTaskId: undefined, + parentTask: undefined, + rootTask: undefined, + }), + ) + expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + }) + + it("does not rehydrate a cancelled child when standalone persistence also fails", async () => { + const childHistory: HistoryItem = { + id: "child-1", + number: 2, + task: "child task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + parentTaskId: "parent-1", + rootTaskId: "root-1", + } + + Object.assign(mockTask1, { + taskId: "child-1", + instanceId: "instance-child", + parentTaskId: "parent-1", + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn(), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + ;(provider as any).clineStack = [mockTask1] + provider.getTaskWithId = vi.fn().mockImplementation((id) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: childHistory }) + } + if (id === "parent-1") { + return Promise.reject(new Error("parent lookup failed")) + } + throw new Error(`unexpected task lookup: ${id}`) + }) as any + + vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("standalone persist failed")) + const createTaskWithHistoryItemSpy = vi + .spyOn(provider, "createTaskWithHistoryItem") + .mockResolvedValue(undefined as any) + + await expect(provider.cancelTask()).rejects.toThrow("standalone persist failed") + expect(createTaskWithHistoryItemSpy).not.toHaveBeenCalled() + expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + }) }) From f1f7cb457a5c7ce147be903ec862ad1ae76a67d7 Mon Sep 17 00:00:00 2001 From: Armando Vaquera Date: Mon, 1 Jun 2026 07:30:33 -0600 Subject: [PATCH 2/9] test(readFileTool): comprehensive coverage; align legacy failure flag with native path (#222) Add unit coverage for ReadFileTool: input validation, rooignore blocking, directory/binary/image handling, image memory limits, approval flow, slice and indentation modes, output structure, and the legacy multi-file format. Source changes: - Drop the temporary "[read_file] Legacy format detected" debug console.warn. - Mirror the native path in the legacy read path: set didToolFailInCurrentTurn on rooignore blocks, directory reads, and read errors so a failed legacy read fails the tool turn consistently. - Recognize re-hydrated bare-`files` calls in isLegacyReadFileParams (history persisted before the _legacyFormat flag existed). Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> --- packages/types/src/tool-params.ts | 9 +- src/core/tools/ReadFileTool.ts | 9 +- src/core/tools/__tests__/readFileTool.spec.ts | 756 ++++++++++++++++++ 3 files changed, 770 insertions(+), 4 deletions(-) diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts index 8c3c4d8d8a..08f5bb9983 100644 --- a/packages/types/src/tool-params.ts +++ b/packages/types/src/tool-params.ts @@ -89,7 +89,14 @@ export type ReadFileToolParams = ReadFileParams | LegacyReadFileParams * Type guard to check if params are in legacy format. */ export function isLegacyReadFileParams(params: ReadFileToolParams): params is LegacyReadFileParams { - return "_legacyFormat" in params && params._legacyFormat === true + // `NativeToolCallParser` always tags freshly parsed legacy calls with `_legacyFormat: true`. + // The bare-`files` fallback only matters for chat history persisted before that flag was + // introduced (commit cc86049f1) and re-hydrated on a later run. Note that params matched via + // that fallback narrow to `LegacyReadFileParams` but leave `_legacyFormat` `undefined`, so + // callers should branch on the presence of `files`, not on `_legacyFormat === true`. + const hasLegacyFlag = "_legacyFormat" in params && params._legacyFormat === true + const hasFilesArray = "files" in params && Array.isArray((params as unknown as Record).files) + return hasLegacyFlag || hasFilesArray } export interface Coordinate { diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8ad6a3b33d..2107cfe21b 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -668,9 +668,6 @@ export class ReadFileTool extends BaseTool<"read_file"> { const { pushToolResult } = callbacks const modelInfo = task.api.getModel().info - // Temporary indicator for testing legacy format detection - console.warn("[read_file] Legacy format detected - using backward compatibility path") - if (!fileEntries || fileEntries.length === 0) { task.consecutiveMistakeCount++ task.recordToolError("read_file") @@ -694,6 +691,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.say("rooignore_error", relPath) const errorMsg = formatResponse.rooIgnoreError(relPath) results.push(`File: ${relPath}\nError: ${errorMsg}`) + // Mirror the native path: a blocked file marks the tool turn as failed. + task.didToolFailInCurrentTurn = true continue } @@ -731,6 +730,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { const errorMsg = `Cannot read '${relPath}' because it is a directory.` results.push(`File: ${relPath}\nError: ${errorMsg}`) await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + // Mirror the native path: a failed read marks the tool turn as failed. + task.didToolFailInCurrentTurn = true continue } @@ -802,6 +803,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + // Mirror the native path: a failed read marks the tool turn as failed. + task.didToolFailInCurrentTurn = true } } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 9e5e78ef8a..170e4b2aff 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -730,4 +730,760 @@ describe("ReadFileTool", () => { expect(description).toBe("[read_file with missing path]") }) }) + + describe("legacy format handling", () => { + it("should detect legacy format and use backward compatibility path", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("legacy content")) + + await readFileTool.execute({ files: [{ path: "legacy.ts" }] } as any, mockTask as any, callbacks) + + // The legacy path emits "File: " entries — proof the backward-compat branch ran. + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: legacy.ts")) + }) + + it("should return error when legacy files array is empty", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ files: [] } as any, mockTask as any, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should handle multiple files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file1 content")) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file2 content")) + + // Override readWithSlice to return content that reflects the actual file data + mockedReadWithSlice + .mockReturnValueOnce({ + content: "1 | file1 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + .mockReturnValueOnce({ + content: "1 | file2 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { files: [{ path: "file1.ts" }, { path: "file2.ts" }] } as any, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file1 content")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file2 content")) + }) + + it("should block rooignore-protected files in legacy format", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ files: [{ path: "secret.env" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) + // Consistent with the native path: a blocked file fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should deny legacy file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ files: [{ path: "protected.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) + }) + + it("should handle directory path in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) + + await readFileTool.execute({ files: [{ path: "src/utils" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle line ranges in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + // fs.readFile with "utf8" encoding returns a string, not a Buffer + mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + + await readFileTool.execute( + { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("2 | line2")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("4 | line4")) + }) + + it("should handle binary image files in legacy format with image support", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", + }) + + await readFileTool.execute({ files: [{ path: "image.png" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Image file - content processed"), + ) + }) + + it("should handle binary image validation failure in legacy format", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image too large", + }) + + await readFileTool.execute({ files: [{ path: "large.png" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Image too large")) + }) + + it("should handle unsupported binary files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + + await readFileTool.execute({ files: [{ path: "program.exe" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot read binary file")) + }) + + it("should handle file read errors in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + await readFileTool.execute({ files: [{ path: "missing.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("ENOENT")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle user feedback on approval in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Read carefully", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ files: [{ path: "test.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) + }) + + it("should handle user feedback on denial in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "Not allowed", + images: undefined, + }) + + await readFileTool.execute({ files: [{ path: "secret.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) + }) + + it("should handle truncation in legacy format when no line ranges", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) + + await readFileTool.execute({ files: [{ path: "large.ts" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File truncated")) + }) + + it("should track file context in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ files: [{ path: "tracked.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("tracked.ts", "read_tool") + }) + }) + + describe("handlePartial", () => { + it("should handle partial display for new format", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { path: "src/app.ts" }, + partial: true, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("readFile"), true) + }) + + it("should handle partial display for legacy format", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { files: [{ path: "legacy.ts" }] }, + partial: false, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("legacy.ts"), false) + }) + + it("should handle partial display with empty path", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { path: "" }, + partial: true, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it("should handle partial display with no nativeArgs", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: undefined, + partial: true, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it("should gracefully handle ask rejection in partial", async () => { + const mockTask = createMockTask() + mockTask.ask.mockRejectedValue(new Error("Cancelled")) + + const block = { + nativeArgs: { path: "test.ts" }, + partial: true, + } + + // Should not throw + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + }) + + describe("processTextFile indentation mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should use offset as fallback when anchor_line is not provided in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | line 5 content", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[5, 5]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 5, + } as any, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 5 }), + ) + }) + + it("should default anchorLine to 1 when neither anchor_line nor offset in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | first line", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + } as any, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 1 }), + ) + }) + + it("should show truncation notice in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | truncated block", + returnedLines: 50, + totalLines: 200, + wasTruncated: true, + includedRanges: [[1, 50]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 1 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File content truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) + }) + + it("should include range information in indentation mode when not truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | function foo() { ... }", + returnedLines: 10, + totalLines: 100, + wasTruncated: false, + includedRanges: [[5, 14]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 5 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Included ranges: 5-14")) + }) + + it("should pass all indentation options to readWithIndentation", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { + anchor_line: 10, + max_levels: 2, + include_siblings: true, + include_header: false, + max_lines: 200, + }, + limit: 500, + }, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + anchorLine: 10, + maxLevels: 2, + includeSiblings: true, + includeHeader: false, + limit: 500, + maxLines: 200, + }), + ) + }) + }) + + describe("slice mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should convert 1-based offset to 0-based for readWithSlice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | line4", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[4, 4]], + }) + + await readFileTool.execute( + { path: "test.ts", mode: "slice", offset: 4, limit: 1 }, + mockTask as any, + callbacks, + ) + + // offset=4 (1-based) should become 3 (0-based) passed to readWithSlice + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 3, 1) + }) + + it("should use default offset of 1 when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + // Default offset=1 -> 0-based = 0 + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 0, expect.any(Number)) + }) + + it("should use default limit when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + // Should use DEFAULT_LINE_LIMIT (which is typically 2000) + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number)) + }) + + it("should show correct line range in truncation notice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content")) + mockedReadWithSlice.mockReturnValue({ + content: "5 | partial", + returnedLines: 100, + totalLines: 500, + wasTruncated: true, + includedRanges: [[5, 104]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 5, limit: 100 }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Showing lines 5-104")) + }) + }) + + describe("getLineSnippet and getStartLine", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should show indentation mode snippet in approval message", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 42 }, + }, + mockTask as any, + callbacks, + ) + + // The approval message should contain the indentation mode info + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 42") + }) + + it("should show line range in approval when offset > 1", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 10, limit: 50 }, mockTask as any, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("lines 10-59") + expect(message.startLine).toBe(10) + }) + + it("should show up to N lines in approval when offset is default", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("up to") + expect(message.reason).toContain("lines") + expect(message.startLine).toBeUndefined() + }) + + it("should show indentation mode snippet with offset fallback", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 7, + } as any, + mockTask as any, + callbacks, + ) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 7") + }) + }) + + describe("provider state handling", () => { + it("should use default image limits when state is null", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // Make providerRef return null + mockTask.providerRef.deref.mockReturnValue(null) + + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + // Should still work - uses default limits + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("should use default limits when state has no image config", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }) + + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + }) + + describe("error handling edge cases", () => { + it("should handle unknown error types (non-Error)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockRejectedValue("string error") + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) + }) + + it("should set didToolFailInCurrentTurn on rooignore block", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "blocked.ts" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should set didToolFailInCurrentTurn on directory read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) + + await readFileTool.execute({ path: "src/" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) }) From f186c6057567e98cc4b7ab6a7e91f08670e0d8d9 Mon Sep 17 00:00:00 2001 From: James Mtendamema <59908268+JamesRobert20@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:09:30 -0600 Subject: [PATCH 3/9] feat(zoo-gateway): provider types, handler, and model fetcher (#344) * feat(zoo-gateway): add provider types, handler, and model fetcher Co-authored-by: Cursor * fix(zoo-gateway): respect readonly client, real version header, safer fetch - Stop reassigning RouterProvider.client; thread Zoo enrichment headers through openAiHeaders so a single OpenAI client is used. - Replace npm_package_version (never populated at extension runtime) with Package.version from the shared package shim. - Default the model list to [] on a structurally broken response so we log and recover instead of crashing on response.data.data being undefined. - Bypass inFlightRefresh de-duplication for zoo-gateway: a refresh triggered after sign-out/sign-in must not return the previous user's in-flight response. - Add fetcher unit tests covering auth header, timeout, error redaction, and bad-response handling. Co-authored-by: Cursor * fix(types): include zoo-gateway key in requestRouterModels record so RouterName stays exhaustive Co-authored-by: Cursor * test(router-models): expect zoo-gateway in requestRouterModels responses Co-authored-by: Cursor * test(zoo-gateway): add ZooGatewayHandler unit tests for codecov patch Cover constructor auth guard, base URL resolution, streaming, task/mode headers, temperature, cache breakpoints, tool calls, and completePrompt. Co-authored-by: Cursor * test(zoo-gateway): mock cached-token + clear-token auth helpers The downstream stack (settings-ui) calls getCachedZooCodeToken and clearZooCodeToken from the auth handler. CI on stacked PRs merges base into head so this spec runs against the cached-token-aware handler; expand the auth module mock so the auth guard test exercises the real throw path instead of vitest's missing-mock-export error. Co-authored-by: Cursor * test(zoo-gateway): align expected OpenAI headers with Zoo Code branding Co-authored-by: Cursor * fix(zoo-gateway): defer auth check, fail-closed models, reuse Vercel schemas Co-authored-by: Cursor * fix(zoo-gateway): accept models list without created/description fields Co-authored-by: Cursor * refactor(zoo-gateway): centralize auth-scoped cache-skip via AUTH_SCOPED_PROVIDERS Co-authored-by: Cursor * chore(types): drop retired 'roo' provider from getApiProtocol allowlist Co-authored-by: Cursor --------- Co-authored-by: James Mtendamema Co-authored-by: Cursor --- packages/types/src/provider-settings.ts | 21 +- packages/types/src/providers/index.ts | 4 + packages/types/src/providers/zoo-gateway.ts | 24 ++ src/api/index.ts | 3 + .../providers/__tests__/zoo-gateway.spec.ts | 339 ++++++++++++++++++ .../fetchers/__tests__/zoo-gateway.spec.ts | 181 ++++++++++ src/api/providers/fetchers/modelCache.ts | 74 ++-- .../providers/fetchers/vercel-ai-gateway.ts | 9 +- src/api/providers/fetchers/zoo-gateway.ts | 84 +++++ src/api/providers/index.ts | 1 + src/api/providers/zoo-gateway.ts | 177 +++++++++ .../webview/__tests__/ClineProvider.spec.ts | 3 + .../__tests__/webviewMessageHandler.spec.ts | 3 + src/core/webview/webviewMessageHandler.ts | 1 + src/shared/api.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 9 + .../src/utils/__tests__/validate.spec.ts | 1 + 17 files changed, 908 insertions(+), 27 deletions(-) create mode 100644 packages/types/src/providers/zoo-gateway.ts create mode 100644 src/api/providers/__tests__/zoo-gateway.spec.ts create mode 100644 src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts create mode 100644 src/api/providers/fetchers/zoo-gateway.ts create mode 100644 src/api/providers/zoo-gateway.ts diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 2daadcd5ef..b2c850eb86 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -38,6 +38,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 export const dynamicProviders = [ "openrouter", "vercel-ai-gateway", + "zoo-gateway", "litellm", "requesty", "unbound", @@ -405,6 +406,12 @@ const opencodeGoSchema = baseProviderSettingsSchema.extend({ opencodeGoModelId: z.string().optional(), }) +const zooGatewaySchema = baseProviderSettingsSchema.extend({ + zooSessionToken: z.string().optional(), + zooGatewayModelId: z.string().optional(), + zooGatewayBaseUrl: z.string().optional(), +}) + const basetenSchema = apiModelIdProviderModelSchema.extend({ basetenApiKey: z.string().optional(), }) @@ -444,6 +451,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })), + zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })), defaultSchema, ]) @@ -479,6 +487,7 @@ export const providerSettingsSchema = z.object({ ...qwenCodeSchema.shape, ...vercelAiGatewaySchema.shape, ...opencodeGoSchema.shape, + ...zooGatewaySchema.shape, ...codebaseIndexProviderSchema.shape, }) @@ -510,6 +519,7 @@ export const modelIdKeys = [ "litellmModelId", "vercelAiGatewayModelId", "opencodeGoModelId", + "zooGatewayModelId", ] as const satisfies readonly (keyof ProviderSettings)[] export type ModelIdKey = (typeof modelIdKeys)[number] @@ -556,6 +566,7 @@ export const modelIdKeysByProvider: Record = { fireworks: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", "opencode-go": "opencodeGoModelId", + "zoo-gateway": "zooGatewayModelId", } /** @@ -574,8 +585,13 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str return "anthropic" } - // Vercel AI Gateway uses anthropic protocol for anthropic models. - if (provider && provider === "vercel-ai-gateway" && modelId && modelId.toLowerCase().startsWith("anthropic/")) { + // Vercel AI Gateway and Zoo Gateway use the anthropic protocol for anthropic models. + if ( + provider && + ["vercel-ai-gateway", "zoo-gateway"].includes(provider) && + modelId && + modelId.toLowerCase().startsWith("anthropic/") + ) { return "anthropic" } @@ -673,6 +689,7 @@ export const MODELS_BY_PROVIDER: Record< unbound: { id: "unbound", label: "Unbound", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, "opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] }, + "zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. lmstudio: { id: "lmstudio", label: "LM Studio", models: [] }, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 60e5e142cc..f283cb474c 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -26,6 +26,7 @@ export * from "./opencode-go.js" export * from "./zai.js" export * from "./minimax.js" export * from "./mimo.js" +export * from "./zoo-gateway.js" import { anthropicDefaultModelId } from "./anthropic.js" import { basetenDefaultModelId } from "./baseten.js" @@ -51,6 +52,7 @@ import { opencodeGoDefaultModelId } from "./opencode-go.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" import { minimaxDefaultModelId } from "./minimax.js" import { mimoDefaultModelId } from "./mimo.js" +import { zooGatewayDefaultModelId } from "./zoo-gateway.js" // Import the ProviderName type from provider-settings to avoid duplication import type { ProviderName } from "../provider-settings.js" @@ -119,6 +121,8 @@ export function getProviderDefaultModelId( return vercelAiGatewayDefaultModelId case "opencode-go": return opencodeGoDefaultModelId + case "zoo-gateway": + return zooGatewayDefaultModelId case "anthropic": case "gemini-cli": case "fake-ai": diff --git a/packages/types/src/providers/zoo-gateway.ts b/packages/types/src/providers/zoo-gateway.ts new file mode 100644 index 0000000000..8596026441 --- /dev/null +++ b/packages/types/src/providers/zoo-gateway.ts @@ -0,0 +1,24 @@ +import type { ModelInfo } from "../model.js" + +// Zoo Gateway uses the same model ID format as Vercel AI Gateway (provider/model-name) +export const zooGatewayDefaultModelId = "anthropic/claude-sonnet-4" + +// Zoo Gateway serves the same models as Vercel AI Gateway, so prompt caching support is identical +// We reuse VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS from vercel-ai-gateway.ts +// Instead of duplicating, we just export a reference to indicate they're the same +export { VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS as ZOO_GATEWAY_PROMPT_CACHING_MODELS } from "./vercel-ai-gateway.js" + +export const zooGatewayDefaultModelInfo: ModelInfo = { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: + "Claude Sonnet 4 significantly improves on Sonnet 3.7's industry-leading capabilities, excelling in coding with a state-of-the-art 72.7% on SWE-bench. The model balances performance and efficiency for internal and external use cases, with enhanced steerability for greater control over implementations.", +} + +export const ZOO_GATEWAY_DEFAULT_TEMPERATURE = 0.7 diff --git a/src/api/index.ts b/src/api/index.ts index 1e83e2d518..e52b41200b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -33,6 +33,7 @@ import { FireworksHandler, VercelAiGatewayHandler, OpencodeGoHandler, + ZooGatewayHandler, MiniMaxHandler, MimoHandler, BasetenHandler, @@ -179,6 +180,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new VercelAiGatewayHandler(options) case "opencode-go": return new OpencodeGoHandler(options) + case "zoo-gateway": + return new ZooGatewayHandler(options) case "minimax": return new MiniMaxHandler(options) case "baseten": diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts new file mode 100644 index 0000000000..76f29cc288 --- /dev/null +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -0,0 +1,339 @@ +// npx vitest run src/api/providers/__tests__/zoo-gateway.spec.ts + +vitest.mock("vscode", () => ({})) + +import OpenAI from "openai" + +import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import { ZooGatewayHandler } from "../zoo-gateway" +import { ApiHandlerOptions } from "../../../shared/api" +import { Package } from "../../../shared/package" + +vitest.mock("openai") +vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) +vitest.mock("../fetchers/modelCache", () => ({ + getModels: vitest.fn().mockImplementation(() => { + return Promise.resolve({ + "anthropic/claude-sonnet-4": { + maxTokens: 64000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: "Claude Sonnet 4", + }, + "anthropic/claude-3.5-haiku": { + maxTokens: 32000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1, + outputPrice: 5, + cacheWritesPrice: 1.25, + cacheReadsPrice: 0.1, + description: "Claude 3.5 Haiku", + }, + }) + }), + getModelsFromCache: vitest.fn().mockReturnValue(undefined), +})) + +vitest.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vitest.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vitest.fn(() => undefined), + clearZooCodeToken: vitest.fn(async () => undefined), +})) + +vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({ + addCacheBreakpoints: vitest.fn(), +})) + +const mockCreate = vitest.fn() + +function mockOpenAIClient() { + vitest.mocked(OpenAI).mockImplementation( + () => + ({ + chat: { + completions: { + create: mockCreate, + }, + }, + }) as unknown as OpenAI, + ) +} + +mockOpenAIClient() + +describe("ZooGatewayHandler", () => { + const mockOptions: ApiHandlerOptions = { + zooSessionToken: "zoo_ext_test_token", + zooGatewayModelId: "anthropic/claude-sonnet-4", + } + + beforeEach(() => { + vitest.clearAllMocks() + mockCreate.mockClear() + mockOpenAIClient() + }) + + describe("constructor", () => { + it("allows construction without a session token (auth is enforced at request time)", () => { + expect(() => new ZooGatewayHandler({})).not.toThrow() + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "not-provided", + }), + ) + }) + + it("initializes OpenAI with Zoo enrichment headers and session token", () => { + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayBaseUrl: "https://staging.zoocode.dev/api/gateway/v1", + }) + + expect(handler).toBeInstanceOf(ZooGatewayHandler) + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: "https://staging.zoocode.dev/api/gateway/v1", + apiKey: mockOptions.zooSessionToken, + defaultHeaders: expect.objectContaining({ + "HTTP-Referer": "https://github.com/Zoo-Code-Org/Zoo-Code", + "X-Title": "Zoo Code", + "X-Zoo-Editor": "vscode", + "X-Zoo-Extension-Version": Package.version, + }), + }) + }) + + it("defaults the gateway base URL from getZooCodeBaseUrl", () => { + new ZooGatewayHandler(mockOptions) + + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://www.zoocode.dev/api/gateway/v1", + }), + ) + }) + }) + + describe("fetchModel", () => { + it("returns configured model info", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const result = await handler.fetchModel() + + expect(result.id).toBe(mockOptions.zooGatewayModelId) + expect(result.info.maxTokens).toBe(64000) + expect(result.info.supportsPromptCache).toBe(true) + }) + + it("falls back to the default model when none is configured", async () => { + const handler = new ZooGatewayHandler({ zooSessionToken: "zoo_ext_test_token" }) + const result = await handler.fetchModel() + + expect(result.id).toBe(zooGatewayDefaultModelId) + }) + }) + + describe("createMessage", () => { + beforeEach(() => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + cache_creation_input_tokens: 2, + prompt_tokens_details: { cached_tokens: 3 }, + cost: 0.005, + }, + } + }, + })) + }) + + it("requires authentication at request time when no session token is available", async () => { + const handler = new ZooGatewayHandler({}) + const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]) + + await expect(async () => { + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow("Zoo Gateway requires authentication. Please sign in to Zoo Code first.") + }) + + it("streams text and usage chunks", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { type: "text", text: "Test response" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 2, + cacheReadTokens: 3, + totalCost: 0.005, + }, + ]) + }) + + it("forwards task and mode metadata as request headers", async () => { + const handler = new ZooGatewayHandler(mockOptions) + + await handler.createMessage("prompt", [], { taskId: "task-123", mode: "code" }).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + headers: { + "X-Zoo-Task-ID": "task-123", + "X-Zoo-Mode": "code", + }, + }), + ) + }) + + it("uses custom temperature when provided", async () => { + const handler = new ZooGatewayHandler({ + ...mockOptions, + modelTemperature: 0.5, + }) + + await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + expect.any(Object), + ) + }) + + it("uses the default temperature when none is provided", async () => { + const handler = new ZooGatewayHandler(mockOptions) + + await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: ZOO_GATEWAY_DEFAULT_TEMPERATURE, + }), + expect.any(Object), + ) + }) + + it("adds cache breakpoints for supported models", async () => { + const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") + const handler = new ZooGatewayHandler({ + ...mockOptions, + zooGatewayModelId: "anthropic/claude-3.5-haiku", + }) + + await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next() + + expect(addCacheBreakpoints).toHaveBeenCalled() + }) + + it("yields tool_call_partial chunks when streaming tool calls", async () => { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { name: "test_tool", arguments: '{"arg1":' }, + }, + ], + }, + index: 0, + }, + ], + } + }, + })) + + const handler = new ZooGatewayHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage("prompt", [])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { + type: "tool_call_partial", + index: 0, + id: "call_123", + name: "test_tool", + arguments: '{"arg1":', + }, + ]) + }) + }) + + describe("completePrompt", () => { + beforeEach(() => { + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: "Test completion response" } }], + })) + }) + + it("returns completion text from the gateway", async () => { + const handler = new ZooGatewayHandler(mockOptions) + + const result = await handler.completePrompt("Complete this: Hello") + + expect(result).toBe("Test completion response") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "anthropic/claude-sonnet-4", + messages: [{ role: "user", content: "Complete this: Hello" }], + stream: false, + temperature: ZOO_GATEWAY_DEFAULT_TEMPERATURE, + max_completion_tokens: 64000, + }), + ) + }) + + it("wraps errors with a Zoo Gateway prefix", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw new Error("upstream failure") + }) + + await expect(handler.completePrompt("Test")).rejects.toThrow( + "Zoo Gateway completion error: upstream failure", + ) + }) + + it("returns an empty string when the model returns no content", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: null } }], + })) + + await expect(handler.completePrompt("Test")).resolves.toBe("") + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts new file mode 100644 index 0000000000..3be70864ea --- /dev/null +++ b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts @@ -0,0 +1,181 @@ +// npx vitest run src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts + +import axios from "axios" + +import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway" + +vitest.mock("axios") +const mockedAxios = axios as any + +describe("Zoo Gateway Fetchers", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + describe("getZooGatewayModels", () => { + const baseUrl = "https://example.test/api/gateway/v1" + const token = "zoo_ext_test_token" + + const mockResponse = { + data: { + object: "list", + data: [ + { + id: "anthropic/claude-sonnet-4", + object: "model", + created: 1640995200, + owned_by: "anthropic", + name: "Claude Sonnet 4", + description: "Sonnet 4", + context_window: 200000, + max_tokens: 64000, + type: "language", + pricing: { + input: "3.00", + output: "15.00", + input_cache_write: "3.75", + input_cache_read: "0.30", + }, + }, + { + id: "image/dall-e-3", + object: "model", + created: 1640995200, + owned_by: "openai", + name: "DALL-E 3", + description: "Image", + context_window: 4000, + max_tokens: 1000, + type: "image", + pricing: { input: "40.00", output: "0.00" }, + }, + ], + }, + } + + it("forwards the bearer token and timeout, filters non-language models", async () => { + mockedAxios.get.mockResolvedValueOnce(mockResponse) + + const models = await getZooGatewayModels({ + zooGatewayBaseUrl: baseUrl, + zooSessionToken: token, + } as any) + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${baseUrl}/models`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `Bearer ${token}` }), + timeout: expect.any(Number), + }), + ) + expect(Object.keys(models)).toHaveLength(1) + expect(models["anthropic/claude-sonnet-4"]).toBeDefined() + }) + + it("omits the Authorization header when no token is provided", async () => { + mockedAxios.get.mockResolvedValueOnce(mockResponse) + + await getZooGatewayModels({ zooGatewayBaseUrl: baseUrl } as any) + + const call = mockedAxios.get.mock.calls[0] + expect(call[1].headers.Authorization).toBeUndefined() + }) + + it("returns {} and never leaks the error object when the request fails", async () => { + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + const failure: any = new Error("Network error") + // Simulate axios attaching the request config (which contains the bearer token). + failure.config = { headers: { Authorization: "Bearer should-never-be-logged" } } + failure.code = "ECONNRESET" + failure.response = { status: 502, statusText: "Bad Gateway" } + mockedAxios.get.mockRejectedValueOnce(failure) + + const models = await getZooGatewayModels({ + zooGatewayBaseUrl: baseUrl, + zooSessionToken: token, + } as any) + + expect(models).toEqual({}) + const logged = consoleErrorSpy.mock.calls.map((args) => String(args[0])).join("\n") + expect(logged).toContain("status=502") + expect(logged).toContain("code=ECONNRESET") + expect(logged).not.toContain("should-never-be-logged") + expect(logged).not.toContain("Authorization") + consoleErrorSpy.mockRestore() + }) + + it("accepts gateway catalog models without created or description (e.g. Bedrock)", async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + object: "list", + data: [ + { + id: "anthropic/claude-sonnet-4", + object: "model", + owned_by: "anthropic", + name: "Claude Sonnet 4", + context_window: 200000, + max_tokens: 64000, + type: "language", + pricing: { + input: "3.00", + output: "15.00", + }, + }, + ], + }, + }) + + const models = await getZooGatewayModels({ + zooGatewayBaseUrl: baseUrl, + zooSessionToken: token, + } as any) + + expect(Object.keys(models)).toEqual(["anthropic/claude-sonnet-4"]) + expect(models["anthropic/claude-sonnet-4"].description).toBe("Claude Sonnet 4") + }) + + it("returns {} on a structurally broken response instead of throwing", async () => { + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + mockedAxios.get.mockResolvedValueOnce({ data: { unexpected: true } }) + + const models = await getZooGatewayModels({ + zooGatewayBaseUrl: baseUrl, + zooSessionToken: token, + } as any) + + expect(models).toEqual({}) + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + }) + + describe("parseZooGatewayModel", () => { + it("delegates to the vercel-ai-gateway parser", () => { + const result = parseZooGatewayModel({ + id: "anthropic/claude-sonnet-4", + model: { + id: "anthropic/claude-sonnet-4", + object: "model", + created: 0, + owned_by: "anthropic", + name: "Claude Sonnet 4", + description: "Sonnet", + context_window: 200000, + max_tokens: 64000, + type: "language", + pricing: { + input: "3.00", + output: "15.00", + input_cache_write: "3.75", + input_cache_read: "0.30", + }, + } as any, + }) + + expect(result.contextWindow).toBe(200000) + expect(result.maxTokens).toBe(64000) + expect(result.supportsPromptCache).toBe(true) + }) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 1589fcffec..404a60cd85 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -27,6 +27,7 @@ import { getOllamaModels } from "./ollama" import { getLMStudioModels } from "./lmstudio" import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" +import { getZooGatewayModels } from "./zoo-gateway" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -37,6 +38,16 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) // This prevents race conditions where multiple calls might overwrite each other's results 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"]) + +function isAuthScopedProvider(provider: RouterName): boolean { + return AUTH_SCOPED_PROVIDERS.has(provider) +} + async function writeModels(router: RouterName, data: ModelRecord) { const filename = `${router}_models.json` const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath) @@ -96,6 +107,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise => { const { provider } = options - let models = getModelsFromCache(provider) + const shouldSkipCache = isAuthScopedProvider(provider) + + let models = shouldSkipCache ? undefined : getModelsFromCache(provider) if (models) { return models @@ -130,15 +146,15 @@ export const getModels = async (options: GetModelsOptions): Promise models = await fetchModelsFromProvider(options) const modelCount = Object.keys(models).length - // Only cache non-empty results to prevent persisting failed API responses - // Empty results could indicate API failure rather than "no models exist" - if (modelCount > 0) { + // Only cache non-empty results so a failed API response doesn't get persisted + // as if the provider had no models. Auth-scoped providers skip caching entirely. + if (modelCount > 0 && !shouldSkipCache) { memoryCache.set(provider, models) await writeModels(provider, models).catch((err) => console.error(`[MODEL_CACHE] Error writing ${provider} models to file cache:`, err), ) - } else { + } else if (modelCount === 0) { TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, { provider, context: "getModels", @@ -167,12 +183,19 @@ export const getModels = async (options: GetModelsOptions): Promise export const refreshModels = async (options: GetModelsOptions): Promise => { const { provider } = options - // Check if there's already an in-flight refresh for this provider + const shouldSkipCache = isAuthScopedProvider(provider) + + // Check if there's already an in-flight refresh for this provider. // This prevents race conditions where multiple concurrent refreshes might - // overwrite each other's results - const existingRequest = inFlightRefresh.get(provider) - if (existingRequest) { - return existingRequest + // overwrite each other's results. Skip de-duplication for auth-scoped + // providers because two concurrent calls may carry different tokens + // (e.g., after a sign-out/sign-in within the same session) and we must + // not return the first caller's results to the second caller. + if (!shouldSkipCache) { + const existingRequest = inFlightRefresh.get(provider) + if (existingRequest) { + return existingRequest + } } // Create the refresh promise and track it @@ -183,7 +206,7 @@ export const refreshModels = async (options: GetModelsOptions): Promise - console.error(`[refreshModels] Error writing ${provider} models to disk:`, err), - ) + await writeModels(provider, models).catch((err) => + console.error(`[refreshModels] Error writing ${provider} models to disk:`, err), + ) + } return models } catch (error) { - // Log the error for debugging, then return existing cache if available (graceful degradation) + // Log the error for debugging, then return existing cache if available (graceful degradation). + // For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior + // session, since they could belong to a different user — return empty instead. console.error(`[refreshModels] Failed to refresh ${provider} models:`, error) + if (shouldSkipCache) { + return {} + } return getModelsFromCache(provider) || {} } finally { // Always clean up the in-flight tracking - inFlightRefresh.delete(provider) + if (!shouldSkipCache) { + inFlightRefresh.delete(provider) + } } })() - // Track the in-flight request - inFlightRefresh.set(provider, refreshPromise) + // Track the in-flight request (auth-scoped providers are excluded; see above). + if (!shouldSkipCache) { + inFlightRefresh.set(provider, refreshPromise) + } return refreshPromise } diff --git a/src/api/providers/fetchers/vercel-ai-gateway.ts b/src/api/providers/fetchers/vercel-ai-gateway.ts index a708d106f0..e2fdbf30b5 100644 --- a/src/api/providers/fetchers/vercel-ai-gateway.ts +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -26,10 +26,11 @@ const vercelAiGatewayPricingSchema = z.object({ const vercelAiGatewayModelSchema = z.object({ id: z.string(), object: z.string(), - created: z.number(), + // Zoo Gateway / Bedrock catalog entries omit these; they are not used for routing. + created: z.number().optional(), owned_by: z.string(), name: z.string(), - description: z.string(), + description: z.string().optional(), context_window: z.number(), max_tokens: z.number(), type: z.string(), @@ -42,7 +43,7 @@ export type VercelAiGatewayModel = z.infer * VercelAiGatewayModelsResponse */ -const vercelAiGatewayModelsResponseSchema = z.object({ +export const vercelAiGatewayModelsResponseSchema = z.object({ object: z.string(), data: z.array(vercelAiGatewayModelSchema), }) @@ -110,7 +111,7 @@ export const parseVercelAiGatewayModel = ({ id, model }: { id: string; model: Ve outputPrice: parseApiPrice(model.pricing?.output), cacheWritesPrice, cacheReadsPrice, - description: model.description, + description: model.description ?? model.name, } return modelInfo diff --git a/src/api/providers/fetchers/zoo-gateway.ts b/src/api/providers/fetchers/zoo-gateway.ts new file mode 100644 index 0000000000..bd3cc1b170 --- /dev/null +++ b/src/api/providers/fetchers/zoo-gateway.ts @@ -0,0 +1,84 @@ +import axios from "axios" + +import type { ModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../../services/zoo-code-auth" + +import { + type VercelAiGatewayModel, + parseVercelAiGatewayModel, + vercelAiGatewayModelsResponseSchema, +} from "./vercel-ai-gateway" + +// Bound model discovery so a network stall can't hang provider initialization paths. +const MODEL_DISCOVERY_TIMEOUT_MS = 15_000 + +/** + * getZooGatewayModels + * + * Fetches models from the Zoo Gateway API. Requires authentication via the zoo_ext_ token. + */ + +export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise> { + const models: Record = {} + const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` + + // Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token. + // Fall back to the secret-storage cache when the profile hasn't been seeded yet. + const sessionToken = options?.zooSessionToken || getCachedZooCodeToken() + const headers: Record = {} + if (sessionToken) { + headers["Authorization"] = `Bearer ${sessionToken}` + } + + try { + const response = await axios.get(`${baseURL}/models`, { + headers, + timeout: MODEL_DISCOVERY_TIMEOUT_MS, + }) + const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data) + + if (!result.success) { + console.error(`Zoo Gateway models response is invalid ${JSON.stringify(result.error.format())}`) + return models + } + + for (const model of result.data.data) { + const { id } = model + + // Only include language models for chat inference. + // Embedding models are statically defined in embeddingModels.ts. + if (model.type !== "language") { + continue + } + + models[id] = parseZooGatewayModel({ id, model }) + } + } catch (error) { + // Log only safe fields; never serialize the full error object because it + // includes request config/headers which carry the bearer session token. + const err = error as { + message?: string + name?: string + code?: string + response?: { status?: number; statusText?: string } + } + console.error( + `Error fetching Zoo Gateway models: name=${err.name ?? "Error"} code=${err.code ?? "unknown"} status=${err.response?.status ?? "unknown"} ${err.response?.statusText ?? ""} message=${err.message ?? "unknown error"}`, + ) + } + + return models +} + +/** + * parseZooGatewayModel + * + * Parses a Zoo Gateway model into ModelInfo format. + * Zoo Gateway returns the same format as Vercel AI Gateway, so we can reuse the parsing logic. + */ + +export const parseZooGatewayModel = ({ id, model }: { id: string; model: VercelAiGatewayModel }): ModelInfo => { + return parseVercelAiGatewayModel({ id, model }) +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index bbbbc6beb5..3c0d1e03e3 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -26,6 +26,7 @@ export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" +export { ZooGatewayHandler } from "./zoo-gateway" export { MiniMaxHandler } from "./minimax" export { MimoHandler } from "./mimo" export { BasetenHandler } from "./baseten" diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts new file mode 100644 index 0000000000..bded670814 --- /dev/null +++ b/src/api/providers/zoo-gateway.ts @@ -0,0 +1,177 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + zooGatewayDefaultModelId, + zooGatewayDefaultModelInfo, + ZOO_GATEWAY_DEFAULT_TEMPERATURE, + VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, +} from "@roo-code/types" + +import { ApiHandlerOptions } from "../../shared/api" +import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../services/zoo-code-auth" +import { Package } from "../../shared/package" + +import { ApiStream } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" + +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { RouterProvider } from "./router-provider" + +// Extend OpenAI's CompletionUsage to include Zoo Gateway specific fields (same as Vercel AI Gateway) +interface ZooGatewayUsage extends OpenAI.CompletionUsage { + cache_creation_input_tokens?: number + cost?: number +} + +const ZOO_GATEWAY_AUTH_ERROR = "Zoo Gateway requires authentication. Please sign in to Zoo Code first." + +export class ZooGatewayHandler extends RouterProvider implements SingleCompletionHandler { + constructor(options: ApiHandlerOptions) { + const baseURL = options.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` + + // Prefer the secret-storage cache so a 401 clear takes effect immediately; fall back + // to the profile-persisted token when the user is signed in but seeding hasn't run yet. + const sessionToken = getCachedZooCodeToken() || options.zooSessionToken + + // Merge Zoo-specific enrichment headers into openAiHeaders so they flow through + // the parent's single OpenAI client. We avoid reassigning `this.client` (which + // is declared readonly on RouterProvider) and the wasted client allocation it + // caused. Per-request headers (task id / mode) are set in createMessage below. + super({ + options: { + ...options, + openAiHeaders: { + "X-Zoo-Editor": "vscode", + "X-Zoo-Extension-Version": Package.version, + ...(options.openAiHeaders || {}), + }, + }, + name: "zoo-gateway", + baseURL, + apiKey: sessionToken || "not-provided", + modelId: options.zooGatewayModelId, + defaultModelId: zooGatewayDefaultModelId, + defaultModelInfo: zooGatewayDefaultModelInfo, + }) + } + + private ensureAuthenticated(): void { + const sessionToken = getCachedZooCodeToken() || this.options.zooSessionToken + if (!sessionToken) { + throw new Error(ZOO_GATEWAY_AUTH_ERROR) + } + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + this.ensureAuthenticated() + + const { id: modelId, info } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // Apply prompt caching for models that support it + // Zoo Gateway serves the same models as Vercel AI Gateway, so caching support is identical + if (VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS.has(modelId) && info.supportsPromptCache) { + addCacheBreakpoints(systemPrompt, openAiMessages) + } + + // Build request headers with enrichment metadata + const requestHeaders: Record = {} + if (metadata?.taskId) { + requestHeaders["X-Zoo-Task-ID"] = metadata.taskId + } + if (metadata?.mode) { + requestHeaders["X-Zoo-Mode"] = metadata.mode + } + + const body: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: openAiMessages, + temperature: this.supportsTemperature(modelId) + ? (this.options.modelTemperature ?? ZOO_GATEWAY_DEFAULT_TEMPERATURE) + : undefined, + max_completion_tokens: info.maxTokens, + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + } + + const completion = await this.client.chat.completions.create(body, { + headers: requestHeaders, + }) + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + + // Emit raw tool call chunks - NativeToolCallParser handles state management + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (chunk.usage) { + const usage = chunk.usage as ZooGatewayUsage + yield { + type: "usage", + inputTokens: usage.prompt_tokens || 0, + outputTokens: usage.completion_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens || undefined, + cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined, + totalCost: usage.cost ?? 0, + } + } + } + } + + async completePrompt(prompt: string): Promise { + this.ensureAuthenticated() + + const { id: modelId, info } = await this.fetchModel() + + try { + const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + stream: false, + } + + if (this.supportsTemperature(modelId)) { + requestOptions.temperature = this.options.modelTemperature ?? ZOO_GATEWAY_DEFAULT_TEMPERATURE + } + + requestOptions.max_completion_tokens = info.maxTokens + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Zoo Gateway completion error: ${error.message}`) + } + throw error + } + } +} diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4fe9e131be..16ab791c26 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2477,6 +2477,7 @@ describe("ClineProvider - Router Models", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -2524,6 +2525,7 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, litellm: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -2620,6 +2622,7 @@ describe("ClineProvider - Router Models", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index ebea3c90b6..4af2be1e38 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -369,6 +369,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -456,6 +457,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -512,6 +514,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ad22c28933..b5bda4b147 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -922,6 +922,7 @@ export const webviewMessageHandler = async ( : { openrouter: {}, "vercel-ai-gateway": {}, + "zoo-gateway": {}, litellm: {}, requesty: {}, unbound: {}, diff --git a/src/shared/api.ts b/src/shared/api.ts index 842e1781a2..ea2b478c8d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -171,6 +171,7 @@ type CommonFetchParams = { const dynamicProviderExtras = { openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type + "zoo-gateway": {} as { apiKey?: string; baseUrl?: string }, litellm: {} as { apiKey: string; baseUrl: string }, requesty: {} as { apiKey?: string; baseUrl?: string }, unbound: {} as { apiKey?: string }, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 62d6213722..d3ebb6c0dd 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -358,6 +358,15 @@ function getSelectedModel({ const info = routerModels["opencode-go"]?.[id] ?? opencodeGoDefaultModelInfo return { id, info } } + case "zoo-gateway": { + const id = getValidatedModelId( + apiConfiguration.zooGatewayModelId, + routerModels["zoo-gateway"], + defaultModelId, + ) + const info = routerModels["zoo-gateway"]?.[id] + return { id, info } + } // case "anthropic": // case "fake-ai": default: { diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index dab2b4fefa..ce4b7a7173 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -47,6 +47,7 @@ describe("Model Validation Functions", () => { poe: {}, deepseek: {}, "opencode-go": {}, + "zoo-gateway": {}, } const allowAllOrganization: OrganizationAllowList = { From 9193941704a658165c66c596c3ab03fd93226540 Mon Sep 17 00:00:00 2001 From: Armando Vaquera Date: Tue, 2 Jun 2026 16:24:36 -0600 Subject: [PATCH 4/9] fix(openai): omit temperature when no custom value is set (#242) (#247) When "use custom temperature" is off, the OpenAI-Compatible provider still sent temperature: 0 (the fallback), so the model's server-side default never applied. Per the discussion on #242 (option A), omit the temperature field in that case. Preserves the supportsTemperature gate (#233), model-required defaults (deepseek-reasoner), and a deliberately-set 0. Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> --- src/api/providers/__tests__/openai.spec.ts | 19 ++++++++++++++++--- src/api/providers/openai.ts | 15 ++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index aa3e5b632b..f45b311f63 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -409,13 +409,15 @@ describe("OpenAiHandler", () => { expect(callArgs).not.toHaveProperty("temperature") }) - it("should include temperature by default when supportsTemperature is not set", async () => { + it("should omit temperature by default when no custom temperature is set", async () => { + // Option A: when "use custom temperature" is off (modelTemperature unset) and the model has no + // required default, omit `temperature` so the server's own default applies instead of forcing 0. const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { } expect(mockCreate).toHaveBeenCalled() const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("temperature") + expect(callArgs).not.toHaveProperty("temperature") }) it("should use the configured modelTemperature when supportsTemperature is not false", async () => { @@ -438,6 +440,17 @@ describe("OpenAiHandler", () => { expect(callArgs.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) + it("should still send temperature when the user sets a custom value of 0", async () => { + // A deliberate 0 must be distinguished from "unset" — it is sent, not omitted. + const zeroTempHandler = new OpenAiHandler({ ...mockOptions, modelTemperature: 0 }) + const stream = zeroTempHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) + }) + it("should include max_tokens when includeMaxTokens is true", async () => { const optionsWithMaxTokens: ApiHandlerOptions = { ...mockOptions, @@ -679,7 +692,7 @@ describe("OpenAiHandler", () => { ], stream: true, stream_options: { include_usage: true }, - temperature: 0, + // No custom temperature set → `temperature` is omitted. tools: undefined, tool_choice: undefined, parallel_tool_calls: true, diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 336a290c2f..532ed38ba2 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -155,13 +155,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, // Some OpenAI-Compatible models (e.g. claude-opus-4-7, claude-opus-4-8) reject - // `temperature` as deprecated/unsupported. Honor the model's `supportsTemperature` - // flag and omit it when explicitly set to false (undefined still sends temperature, - // preserving behavior). - ...(modelInfo.supportsTemperature !== false && { - temperature: - this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - }), + // `temperature` as deprecated/unsupported, so honor the model's `supportsTemperature` + // flag and omit it when that flag is false. Beyond that, only send `temperature` when + // the user set a custom value or the model needs a specific default (deepseek-reasoner); + // otherwise omit it so the server's own default applies instead of forcing 0. + ...(modelInfo.supportsTemperature !== false && + (this.options.modelTemperature != null || deepseekReasoner) && { + temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + }), messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), From 99c7ed1bb83d0010e01189f2a442990789d7d4e6 Mon Sep 17 00:00:00 2001 From: Armando Vaquera Date: Tue, 2 Jun 2026 17:33:27 -0600 Subject: [PATCH 5/9] feat(zai): expose configurable max output tokens for GLM models (#161) (#274) * feat(zai): expose configurable max output tokens for GLM models (#161) Add a standalone "max output tokens" slider for models that advertise `supportsMaxTokens` (e.g. Z.ai GLM) but do not surface a reasoning budget, and send the chosen value to the provider as `max_tokens`. - ThinkingBudget: extract the max-tokens slider into a small render helper shared by the standalone control and the reasoning-budget branch, and surface it for binary-reasoning models that also support a configurable max. - getModelMaxOutputTokens: honor the user's `modelMaxTokens` override for `supportsMaxTokens` models (capped at the model ceiling) instead of the 20% context-window clamp, so the runtime budget matches what is sent to the provider. - ProviderSettingsManager.export(): preserve `modelMaxTokens` for configurable-max models while dropping it for models that support neither reasoning budgets nor a configurable max. * Update src/shared/api.ts * Update webview-ui/src/components/settings/ThinkingBudget.tsx --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> --- packages/types/src/model.ts | 4 + packages/types/src/providers/zai.ts | 4 + src/api/providers/__tests__/zai.spec.ts | 40 ++++ src/core/config/ProviderSettingsManager.ts | 11 +- .../__tests__/ProviderSettingsManager.spec.ts | 73 ++++++++ src/shared/__tests__/api.spec.ts | 35 ++++ src/shared/api.ts | 7 + .../components/settings/ThinkingBudget.tsx | 175 +++++++++++------- .../__tests__/ThinkingBudget.spec.tsx | 110 +++++++++++ 9 files changed, 391 insertions(+), 68 deletions(-) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 926f29a0a1..a86eadfaf9 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -81,6 +81,10 @@ export const modelInfoSchema = z.object({ promptCacheRetention: z.enum(["in_memory", "24h"]).optional(), // Capability flag to indicate whether the model supports an output verbosity parameter supportsVerbosity: z.boolean().optional(), + // Capability flag to indicate whether the model exposes a user-configurable max output + // tokens control in settings. When set, the settings UI surfaces a slider that persists + // `modelMaxTokens`; when the user leaves it unset, the default output clamp is used. + supportsMaxTokens: z.boolean().optional(), supportsReasoningBudget: z.boolean().optional(), // Capability flag to indicate whether the model supports simple on/off binary reasoning supportsReasoningBinary: z.boolean().optional(), diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts index dacc9f210a..da5435dbec 100644 --- a/packages/types/src/providers/zai.ts +++ b/packages/types/src/providers/zai.ts @@ -142,6 +142,7 @@ export const internationalZAiModels = { contextWindow: 200_000, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, @@ -157,6 +158,7 @@ export const internationalZAiModels = { contextWindow: 202_752, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, @@ -348,6 +350,7 @@ export const mainlandZAiModels = { contextWindow: 204_800, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, @@ -363,6 +366,7 @@ export const mainlandZAiModels = { contextWindow: 202_752, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 127d9ee7ab..1103dd6c8e 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -503,6 +503,46 @@ describe("ZAiHandler", () => { ) }) + it("should advertise supportsMaxTokens for configurable GLM models", () => { + expect(internationalZAiModels["glm-5.1"].supportsMaxTokens).toBe(true) + expect(internationalZAiModels["glm-5-turbo"].supportsMaxTokens).toBe(true) + expect(mainlandZAiModels["glm-5.1"].supportsMaxTokens).toBe(true) + expect(mainlandZAiModels["glm-5-turbo"].supportsMaxTokens).toBe(true) + // Models without a configurable output budget should not advertise the flag. + expect((internationalZAiModels["glm-4.7"] as { supportsMaxTokens?: boolean }).supportsMaxTokens).toBe( + undefined, + ) + }) + + it("should honor an explicit modelMaxTokens override instead of the 20% clamp", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5.1", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + modelMaxTokens: 100_000, + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-5.1", + max_tokens: 100_000, + }), + ) + }) + it("should enable thinking by default for GLM-4.7 (default reasoningEffort is medium)", async () => { const handlerWithModel = new ZAiHandler({ apiModelId: "glm-4.7", diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 7195b11373..036455c0d8 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -567,11 +567,18 @@ export class ProviderSettingsManager { const supportsReasoningBudget = modelInfo.supportsReasoningBudget || modelInfo.requiredReasoningBudget - // If the model doesn't support reasoning budgets, remove the token fields + // modelMaxThinkingTokens only applies to reasoning budgets, but modelMaxTokens + // also caps output on models that expose a configurable max (e.g. GLM), so keep + // it whenever the model supports either feature. + const supportsMaxTokens = supportsReasoningBudget || modelInfo.supportsMaxTokens + if (!supportsReasoningBudget) { - delete configs[name].modelMaxTokens delete configs[name].modelMaxThinkingTokens } + + if (!supportsMaxTokens) { + delete configs[name].modelMaxTokens + } } catch (error) { // If we can't build the API handler or get model info, skip filtering // to avoid accidental data loss from incomplete configurations diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 1099888ae3..c6bd19c0b1 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -6,6 +6,33 @@ import type { ProviderSettings } from "@roo-code/types" import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +// `export()` builds an API handler per profile to read model capabilities. Mock +// buildApiHandler with the real @roo-code/types model definitions so the token-field +// filtering is driven by real capability flags, and so this suite stays isolated from +// sibling specs that also mock "../../../api" (avoids a cross-file mock leak under +// Vitest's singleFork pool). +vi.mock("../../../api", async () => { + const types = await vi.importActual("@roo-code/types") + const zaiModels = { ...types.internationalZAiModels, ...types.mainlandZAiModels } as Record + const anthropicModels = types.anthropicModels as Record + const modelInfoFor = (config: { apiProvider?: string; apiModelId?: string }) => { + const id = config?.apiModelId ?? "" + switch (config?.apiProvider) { + case "zai": + return zaiModels[id] ?? {} + case "anthropic": + return anthropicModels[id] ?? {} + default: + return {} + } + } + return { + buildApiHandler: (config: any) => ({ + getModel: () => ({ id: config?.apiModelId ?? "", info: modelInfoFor(config) }), + }), + } +}) + // Mock VSCode ExtensionContext const mockSecrets = { get: vi.fn(), @@ -874,6 +901,52 @@ describe("ProviderSettingsManager", () => { expect(exported.apiConfigs.retired.modelMaxTokens).toBe(4096) expect(exported.apiConfigs.retired.modelMaxThinkingTokens).toBe(2048) }) + + it("should preserve modelMaxTokens for models that support a configurable max output (e.g. GLM)", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "glm", + apiConfigs: { + glm: { + id: "glm-id", + apiProvider: "zai", + apiModelId: "glm-5.1", + modelMaxTokens: 8192, + modelMaxThinkingTokens: 2048, + }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const exported = await providerSettingsManager.export() + + // GLM exposes a configurable max output (supportsMaxTokens) but no reasoning budget, + // so modelMaxTokens must survive the export while modelMaxThinkingTokens is dropped. + expect(exported.apiConfigs.glm.modelMaxTokens).toBe(8192) + expect(exported.apiConfigs.glm.modelMaxThinkingTokens).toBeUndefined() + }) + + it("should strip both token fields for models that support neither reasoning budgets nor a configurable max", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "anthropic", + apiConfigs: { + anthropic: { + id: "anthropic-id", + apiProvider: "anthropic", + apiModelId: "claude-3-5-haiku-20241022", + modelMaxTokens: 8192, + modelMaxThinkingTokens: 2048, + }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const exported = await providerSettingsManager.export() + + expect(exported.apiConfigs.anthropic.modelMaxTokens).toBeUndefined() + expect(exported.apiConfigs.anthropic.modelMaxThinkingTokens).toBeUndefined() + }) }) describe("ResetAllConfigs", () => { diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 06d3a5b3f1..be95a3cab7 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -240,6 +240,41 @@ describe("getModelMaxOutputTokens", () => { }) }) + test("should honor the user's modelMaxTokens override for supportsMaxTokens models (e.g. Z.ai GLM)", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: false, + supportsMaxTokens: true, + maxTokens: 98_304, // model ceiling, well above the 20% clamp (40k) + } + + const settings: ProviderSettings = { + apiProvider: "zai", + modelMaxTokens: 64_000, // user override, above 20% of the context window (40k) + } + + const result = getModelMaxOutputTokens({ modelId: "glm-4.6", model, settings, format: "openai" }) + // Honored instead of clamped to 20% of the context window. + expect(result).toBe(64_000) + }) + + test("should cap the supportsMaxTokens override at the model's own maxTokens ceiling", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: false, + supportsMaxTokens: true, + maxTokens: 32_000, + } + + const settings: ProviderSettings = { + apiProvider: "zai", + modelMaxTokens: 999_999, // beyond the model ceiling + } + + const result = getModelMaxOutputTokens({ modelId: "glm-4.6", model, settings, format: "openai" }) + expect(result).toBe(32_000) + }) + test("should still apply 20% cap to non-GPT-5 models", () => { const model: ModelInfo = { contextWindow: 200_000, diff --git a/src/shared/api.ts b/src/shared/api.ts index ea2b478c8d..c0db55f661 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -132,6 +132,13 @@ export const getModelMaxOutputTokens = ({ return ANTHROPIC_DEFAULT_MAX_TOKENS } + // Models that expose a configurable max-output slider (e.g. Z.ai GLM) honor the user's + // explicit override instead of the default 20% context-window clamp, capped at the model's + // own ceiling. This keeps the runtime budget consistent with the value sent to the provider. + if (model.supportsMaxTokens && settings?.modelMaxTokens != null && settings.modelMaxTokens > 0) { + return model.maxTokens ? Math.min(settings.modelMaxTokens, model.maxTokens) : settings.modelMaxTokens + } + // If model has explicit maxTokens, clamp it to 20% of the context window // Exception: GPT-5 models should use their exact configured max output tokens if (model.maxTokens) { diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index c0c4a79bb2..0f7af4d549 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -46,6 +46,7 @@ import { DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS, DEFAULT_HYBRID_REASONING_MODEL_THINKING_TOKENS, GEMINI_25_PRO_MIN_THINKING_TOKENS, + getModelMaxOutputTokens, } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -75,6 +76,10 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod const isReasoningBudgetSupported = !!modelInfo && modelInfo.supportsReasoningBudget const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort + // Models that advertise a user-configurable max output budget (e.g. Z.ai GLM) but do not + // use the reasoning-budget slider. The reasoning-budget branch already renders its own + // max-tokens control, so only surface this standalone slider when that branch is inactive. + const isMaxTokensConfigurable = !!modelInfo && modelInfo.supportsMaxTokens && !isReasoningBudgetSupported // Build available reasoning efforts list from capability const supports = modelInfo?.supportsReasoningEffort @@ -160,22 +165,63 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod } }, [isReasoningBudgetSupported, customMaxThinkingTokens, modelMaxThinkingTokens, setApiConfigurationField]) + // Default max output budget for models that expose a standalone max-tokens slider. + // When the user hasn't set an explicit `modelMaxTokens`, fall back to the same value + // the runtime would use (the default output clamp) so behavior is unchanged. + const defaultMaxOutputTokens = + (isMaxTokensConfigurable && selectedModelId && modelInfo + ? getModelMaxOutputTokens({ modelId: selectedModelId, model: modelInfo, settings: apiConfiguration }) + : undefined) ?? + modelInfo?.maxTokens ?? + DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS + const standaloneMaxOutputTokens = apiConfiguration.modelMaxTokens ?? defaultMaxOutputTokens + if (!modelInfo) { return null } - // Models with supportsReasoningBinary (binary reasoning) show a simple on/off toggle + // Shared markup for the "Max Output Tokens" slider, reused by the standalone control + // (supportsMaxTokens models) and the reasoning-budget branch below. + const renderMaxTokensSlider = (min: number, max: number, value: number, testId?: string) => ( +
+
{t("settings:thinkingBudget.maxTokens")}
+
+ setApiConfigurationField("modelMaxTokens", newValue)} + /> +
{value}
+
+
+ ) + + // Standalone max output tokens slider for models that advertise `supportsMaxTokens` + // (e.g. Z.ai GLM) but do not surface the reasoning-budget control. + const maxOutputTokensControl = + isMaxTokensConfigurable && modelInfo.maxTokens + ? renderMaxTokensSlider(1024, modelInfo.maxTokens, standaloneMaxOutputTokens, "max-output-tokens") + : null + + // Models with supportsReasoningBinary (binary reasoning) show a simple on/off toggle. + // A binary-reasoning model can still advertise `supportsMaxTokens`, so surface the + // standalone max-output slider alongside the toggle when it applies. if (isReasoningSupported) { return ( -
- - setApiConfigurationField("enableReasoningEffort", checked === true) - }> - {t("settings:providers.useReasoning")} - -
+ <> + {maxOutputTokensControl} +
+ + setApiConfigurationField("enableReasoningEffort", checked === true) + }> + {t("settings:providers.useReasoning")} + +
+ ) } @@ -194,23 +240,15 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod )} {(isReasoningBudgetRequired || enableReasoningEffort) && ( <> -
-
{t("settings:thinkingBudget.maxTokens")}
-
- setApiConfigurationField("modelMaxTokens", value)} - /> -
{customMaxOutputTokens}
-
-
+ {renderMaxTokensSlider( + 8192, + Math.max( + modelInfo.maxTokens || 8192, + customMaxOutputTokens, + DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS, + ), + customMaxOutputTokens, + )}
{t("settings:thinkingBudget.maxThinkingTokens")}
@@ -228,44 +266,49 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod )} ) : isReasoningEffortSupported ? ( -
-
- -
- { + // "disable" turns off reasoning entirely; "none" is a valid reasoning level + if (value === "disable") { + setApiConfigurationField("enableReasoningEffort", false) + setApiConfigurationField("reasoningEffort", "disable") + } else { + // "none", "minimal", "low", "medium", "high" all enable reasoning + setApiConfigurationField("enableReasoningEffort", true) + setApiConfigurationField("reasoningEffort", value as ReasoningEffortWithMinimal) } - /> - - - {availableOptions.map((value) => ( - - {value === "none" || value === "disable" - ? t("settings:providers.reasoningEffort.none") - : t(`settings:providers.reasoningEffort.${value}`)} - - ))} - - -
- ) : null + }}> + + + + + {availableOptions.map((value) => ( + + {value === "none" || value === "disable" + ? t("settings:providers.reasoningEffort.none") + : t(`settings:providers.reasoningEffort.${value}`)} + + ))} + + +
+ + ) : ( + maxOutputTokensControl + ) } diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 141d518feb..5b18b881bf 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -305,4 +305,114 @@ describe("ThinkingBudget", () => { expect(screen.getByTestId("select-item-high")).toBeInTheDocument() }) }) + + describe("configurable max output tokens (supportsMaxTokens)", () => { + // Mirrors Z.ai GLM models: max output budget plus a reasoning-effort dropdown, + // but no reasoning-budget control. + const glmModelInfo: ModelInfo = { + supportsMaxTokens: true, + supportsReasoningEffort: ["disable", "medium"], + maxTokens: 131072, + contextWindow: 200000, + supportsPromptCache: true, + } + + const glmApiConfiguration = { apiProvider: "zai", apiModelId: "glm-5.1" } as const + + it("should render the max output tokens slider alongside the reasoning effort dropdown", () => { + render( + , + ) + + expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument() + }) + + it("should default the slider to the 20% clamp when modelMaxTokens is unset", () => { + render( + , + ) + + // 20% of 200000 = 40000 (the runtime clamp), since maxTokens (131072) exceeds it. + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider).toHaveValue("40000") + }) + + it("should reflect an explicit modelMaxTokens override on the slider", () => { + render( + , + ) + + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider).toHaveValue("100000") + }) + + it("should NOT persist modelMaxTokens on initial render (no user action)", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + // Initialization must not write the default clamp back to settings. + expect(setApiConfigurationField).not.toHaveBeenCalledWith("modelMaxTokens", expect.anything()) + expect(setApiConfigurationField).not.toHaveBeenCalledWith( + "modelMaxTokens", + expect.anything(), + expect.anything(), + ) + }) + + it("should persist modelMaxTokens as a user action when the slider changes", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + fireEvent.change(slider, { target: { value: "65536" } }) + + // A real user edit persists modelMaxTokens without the isUserAction=false flag. + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 65536) + }) + + it("should not render the standalone slider when supportsMaxTokens is absent", () => { + render( + , + ) + + expect(screen.queryByTestId("max-output-tokens")).not.toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument() + }) + }) }) From f9c9c09e5be15fe51b92a4d037e831f67d650b80 Mon Sep 17 00:00:00 2001 From: James Mtendamema <59908268+JamesRobert20@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:13:11 -0600 Subject: [PATCH 6/9] feat(zoo-gateway): settings UI, validation, and i18n (#345) * feat(zoo-gateway): add provider types, handler, and model fetcher Co-authored-by: Cursor * fix(zoo-gateway): respect readonly client, real version header, safer fetch - Stop reassigning RouterProvider.client; thread Zoo enrichment headers through openAiHeaders so a single OpenAI client is used. - Replace npm_package_version (never populated at extension runtime) with Package.version from the shared package shim. - Default the model list to [] on a structurally broken response so we log and recover instead of crashing on response.data.data being undefined. - Bypass inFlightRefresh de-duplication for zoo-gateway: a refresh triggered after sign-out/sign-in must not return the previous user's in-flight response. - Add fetcher unit tests covering auth header, timeout, error redaction, and bad-response handling. Co-authored-by: Cursor * test(zoo-gateway): mock cached-token + clear-token auth helpers The downstream stack (settings-ui) calls getCachedZooCodeToken and clearZooCodeToken from the auth handler. CI on stacked PRs merges base into head so this spec runs against the cached-token-aware handler; expand the auth module mock so the auth guard test exercises the real throw path instead of vitest's missing-mock-export error. Co-authored-by: Cursor * feat(zoo-gateway): add settings UI, validation, and i18n Co-authored-by: Cursor * fix(zoo-gateway): dynamic dashboard URL and cached-token fallback - Resolve ModelPicker serviceUrl from zooCodeBaseUrl so staging/dev environments link to the matching dashboard. - Fall back to getCachedZooCodeToken() in the handler and model fetcher when the profile has not been seeded yet (auth before webview open). Co-authored-by: Cursor * fix(deps): drop stale webview-ui package.json drift that broke frozen-lockfile installs Co-authored-by: Cursor * fix(i18n): restore googleCloudCredentialsPathWarning and automaticFetch 'free' search hint dropped on rebase Co-authored-by: Cursor * fix(zoo-gateway): pick default model from fetched list, prefer Sonnet 4.5 Resolves Sonnet 4.5 from the gateway model catalog instead of a static Vercel slug so test (Bedrock) and live accounts both get a valid default. Reassigns stale profile model IDs when they are not in the catalog. Co-authored-by: Cursor * test(zoo-gateway): cover dynamic default model picker for codecov patch Exports pickZooGatewayDefaultModelId so the helper is unit-testable and adds component tests for the auto-default useEffect (no-op while catalog loads, auto-pick on empty profile, repair stale id, no-op when valid). Co-authored-by: Cursor * fix(i18n): restore UTF-8 for Google Cloud warning and model picker strings Co-authored-by: Cursor * fix(zoo-gateway): settings UI sign-in button, validation tests, defer auth Co-authored-by: Cursor * refactor(zoo-gateway): localize auth-state UX to provider component Move the zoo-gateway sign-in error out of the shared form-validation effect in ApiOptions and into ZooGateway.tsx, where it renders inline via ApiErrorMessage. ApiOptions can then drop zooCodeIsAuthenticated from its useEffect dependency list, and validateApiConfigurationExcludingModelErrors short-circuits zoo-gateway entirely. Also rename the inline isSonnet45ModelId helper to isClaudeSonnetModelId and let pickZooGatewayDefaultModelId express the version-priority order directly, so the helper has no version baked into its name. Co-authored-by: Cursor * fix(zoo-gateway): enforce org provider check, simplify sign-in copy The settings-form short-circuit for zoo-gateway also bypassed the organization PROVIDER_NOT_ALLOWED check, so a workspace that disallows zoo-gateway could not surface that error. Scope the short-circuit to the keys/sign-in check only and let the org allowlist check run for every provider. Drop the quoted CTA from zooGatewaySignIn in all 18 locales: the sign-in button is rendered immediately below the inline error, so a duplicate label was just a drift hazard. Co-authored-by: Cursor * refactor(webview): move provider model config + docs slugs out of ApiOptions Lift the inline PROVIDER_MODEL_CONFIG map and the docs-slug lookup out of ApiOptions.tsx into webview-ui/src/components/settings/utils/providerModelConfig.ts behind getProviderModelConfig and getProviderDocsSlug helpers. ApiOptions now reads provider-specific model fields, defaults, and docs slugs through those helpers, leaving the component focused on rendering. Co-authored-by: Cursor * fix(i18n): align French zooGatewaySignIn to formal vous register validation.zooGatewaySignIn used "Connecte-toi" while providers.zooGateway.signInDescription uses "Connectez-vous". Use vous consistently across both strings. Co-authored-by: Cursor --------- Co-authored-by: James Mtendamema Co-authored-by: Cursor --- .../fetchers/__tests__/zoo-gateway.spec.ts | 5 +- .../src/components/settings/ApiOptions.tsx | 108 +++------- .../src/components/settings/ModelPicker.tsx | 1 + .../settings/__tests__/ApiOptions.spec.tsx | 24 ++- .../src/components/settings/constants.ts | 1 + .../settings/providers/ZooGateway.tsx | 125 ++++++++++++ .../providers/__tests__/ZooGateway.spec.tsx | 189 ++++++++++++++++++ .../components/settings/providers/index.ts | 1 + .../settings/utils/providerModelConfig.ts | 71 +++++++ .../welcome/WelcomeViewProvider.tsx | 14 +- webview-ui/src/i18n/locales/ca/settings.json | 10 +- webview-ui/src/i18n/locales/de/settings.json | 10 +- webview-ui/src/i18n/locales/en/settings.json | 10 +- webview-ui/src/i18n/locales/es/settings.json | 10 +- webview-ui/src/i18n/locales/fr/settings.json | 10 +- webview-ui/src/i18n/locales/hi/settings.json | 10 +- webview-ui/src/i18n/locales/id/settings.json | 10 +- webview-ui/src/i18n/locales/it/settings.json | 10 +- webview-ui/src/i18n/locales/ja/settings.json | 10 +- webview-ui/src/i18n/locales/ko/settings.json | 10 +- webview-ui/src/i18n/locales/nl/settings.json | 10 +- webview-ui/src/i18n/locales/pl/settings.json | 10 +- .../src/i18n/locales/pt-BR/settings.json | 10 +- webview-ui/src/i18n/locales/ru/settings.json | 10 +- webview-ui/src/i18n/locales/tr/settings.json | 10 +- webview-ui/src/i18n/locales/vi/settings.json | 10 +- .../src/i18n/locales/zh-CN/settings.json | 10 +- .../src/i18n/locales/zh-TW/settings.json | 10 +- .../src/utils/__tests__/validate.spec.ts | 97 ++++++++- webview-ui/src/utils/validate.ts | 26 ++- 30 files changed, 731 insertions(+), 111 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/ZooGateway.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx diff --git a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts index 3be70864ea..f502b3a416 100644 --- a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts @@ -5,6 +5,10 @@ import axios from "axios" import { getZooGatewayModels, parseZooGatewayModel } from "../zoo-gateway" vitest.mock("axios") +vitest.mock("../../../../services/zoo-code-auth", () => ({ + getCachedZooCodeToken: vitest.fn(() => ""), + getZooCodeBaseUrl: vitest.fn(() => "https://example.test"), +})) const mockedAxios = axios as any describe("Zoo Gateway Fetchers", () => { @@ -134,7 +138,6 @@ describe("Zoo Gateway Fetchers", () => { expect(Object.keys(models)).toEqual(["anthropic/claude-sonnet-4"]) expect(models["anthropic/claude-sonnet-4"].description).toBe("Claude Sonnet 4") }) - it("returns {} on a structurally broken response instead of throwing", async () => { const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) mockedAxios.get.mockResolvedValueOnce({ data: { unexpected: true } }) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2b724e6dde..a6edab4e1a 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -9,36 +9,13 @@ import { type ProviderSettings, isRetiredProvider, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, - openRouterDefaultModelId, - poeDefaultModelId, - requestyDefaultModelId, - litellmDefaultModelId, - openAiNativeDefaultModelId, - openAiCodexDefaultModelId, - anthropicDefaultModelId, - qwenCodeDefaultModelId, - geminiDefaultModelId, - deepSeekDefaultModelId, - moonshotDefaultModelId, - mistralDefaultModelId, - xaiDefaultModelId, - basetenDefaultModelId, - bedrockDefaultModelId, - vertexDefaultModelId, - sambaNovaDefaultModelId, - internationalZAiDefaultModelId, - mainlandZAiDefaultModelId, - fireworksDefaultModelId, - vercelAiGatewayDefaultModelId, - opencodeGoDefaultModelId, - minimaxDefaultModelId, - mimoDefaultModelId, - unboundDefaultModelId, } from "@roo-code/types" import { getProviderServiceConfig, getDefaultModelIdForProvider, + getProviderDocsSlug, + getProviderModelConfig, getStaticModelsForProvider, shouldUseGenericModelPicker, handleModelChangeSideEffects, @@ -95,6 +72,7 @@ import { Fireworks, VercelAiGateway, OpenCodeGo, + ZooGateway, MiniMax, Mimo, } from "./providers" @@ -268,6 +246,14 @@ const ApiOptions = ({ return } + // Zoo Gateway renders its own auth-state error inline (sign-in card in + // ZooGateway.tsx) so it can react to zooCodeIsAuthenticated changes + // without re-running this effect or threading auth state through validation. + if (apiConfiguration.apiProvider === "zoo-gateway") { + setErrorMessage(undefined) + return + } + const apiValidationResult = validateApiConfigurationExcludingModelErrors( apiConfiguration, routerModels, @@ -326,52 +312,7 @@ const ApiOptions = ({ } } - // Define a mapping object that associates each provider with its model configuration - const PROVIDER_MODEL_CONFIG: Partial< - Record< - ProviderName, - { - field: keyof ProviderSettings - default?: string - } - > - > = { - openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, - requesty: { field: "requestyModelId", default: requestyDefaultModelId }, - unbound: { field: "unboundModelId", default: unboundDefaultModelId }, - litellm: { field: "litellmModelId", default: litellmDefaultModelId }, - anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, - "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, - "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, - "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, - gemini: { field: "apiModelId", default: geminiDefaultModelId }, - deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, - moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, - minimax: { field: "apiModelId", default: minimaxDefaultModelId }, - mimo: { field: "apiModelId", default: mimoDefaultModelId }, - mistral: { field: "apiModelId", default: mistralDefaultModelId }, - xai: { field: "apiModelId", default: xaiDefaultModelId }, - baseten: { field: "apiModelId", default: basetenDefaultModelId }, - bedrock: { field: "apiModelId", default: bedrockDefaultModelId }, - vertex: { field: "apiModelId", default: vertexDefaultModelId }, - sambanova: { field: "apiModelId", default: sambaNovaDefaultModelId }, - zai: { - field: "apiModelId", - default: - apiConfiguration.zaiApiLine === "china_coding" - ? mainlandZAiDefaultModelId - : internationalZAiDefaultModelId, - }, - fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, - poe: { field: "apiModelId", default: poeDefaultModelId }, - "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, - "opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, - openai: { field: "openAiModelId" }, - ollama: { field: "ollamaModelId" }, - lmstudio: { field: "lmStudioModelId" }, - } - - const config = PROVIDER_MODEL_CONFIG[value] + const config = getProviderModelConfig(value, apiConfiguration) if (config) { validateAndResetModel( value, @@ -390,22 +331,14 @@ const ApiOptions = ({ const docs = useMemo(() => { const provider = PROVIDERS.find(({ value }) => value === selectedProvider) - const name = provider?.label - - if (!name) { + if (!provider) { return undefined } - // Get the URL slug - use custom mapping if available, otherwise use the provider key. - const slugs: Record = { - "openai-native": "openai", - openai: "openai-compatible", - } - - const slug = slugs[selectedProvider] || selectedProvider + const slug = getProviderDocsSlug(provider.value) return { url: buildDocLink(`providers/${slug}`, "provider_docs"), - name, + name: provider.label, } }, [selectedProvider]) @@ -702,6 +635,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === "zoo-gateway" && ( + + )} + {selectedProvider === "fireworks" && ( { expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiModelId", openAiCodexDefaultModelId, false) }) + it("initializes zooGatewayModelId to its default when switching provider to zoo-gateway", () => { + // Regression: zoo-gateway was previously missing from PROVIDER_MODEL_CONFIG, so switching + // providers never seeded zooGatewayModelId. Configs were left without a model id, which + // blocked completion flows that require a dynamic-provider model id. + const mockSetApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + // No prior zooGatewayModelId. + }, + setApiConfigurationField: mockSetApiConfigurationField, + }) + + const providerSelectContainer = screen.getByTestId("provider-select") + const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement + fireEvent.change(providerSelect, { target: { value: "zoo-gateway" } }) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiProvider", "zoo-gateway") + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("zooGatewayModelId", zooGatewayDefaultModelId, false) + }) + it("shows temperature and rate limit controls by default", () => { renderApiOptions({ apiConfiguration: {}, diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 6769bda65d..566370c837 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -64,6 +64,7 @@ export const PROVIDERS = [ { value: "fireworks", label: "Fireworks AI", proxy: false }, { value: "vercel-ai-gateway", label: "Vercel AI Gateway", proxy: false }, { value: "opencode-go", label: "Opencode Go", proxy: false }, + { value: "zoo-gateway", label: "Zoo Gateway", proxy: false }, { value: "minimax", label: "MiniMax", proxy: false }, { value: "mimo", label: "Xiaomi MiMo", proxy: false }, { value: "baseten", label: "Baseten", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/ZooGateway.tsx b/webview-ui/src/components/settings/providers/ZooGateway.tsx new file mode 100644 index 0000000000..ac99f464a4 --- /dev/null +++ b/webview-ui/src/components/settings/providers/ZooGateway.tsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo } from "react" +import { + type ProviderSettings, + type OrganizationAllowList, + type RouterModels, + zooGatewayDefaultModelId, +} from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { getZooCodeAuthUrl } from "@src/oauth/urls" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { ModelPicker } from "../ModelPicker" +import { ApiErrorMessage } from "../ApiErrorMessage" + +type ZooGatewayProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +function isClaudeSonnetModelId(id: string) { + return /claude.*sonnet/i.test(id) +} + +// Exported for unit tests. Picks the default Zoo Gateway model id, preferring +// Claude Sonnet 4.5 → Sonnet 4 → first available Sonnet → first model overall. +export function pickZooGatewayDefaultModelId(modelIds: string[]) { + if (modelIds.length === 0) { + return zooGatewayDefaultModelId + } + + const sonnets = modelIds.filter(isClaudeSonnetModelId) + if (sonnets.length === 0) { + return modelIds[0] + } + + return ( + sonnets.find((id) => id === "anthropic/claude-sonnet-4.5") ?? + sonnets.find((id) => id.includes("claude-sonnet-4.5")) ?? + sonnets.find((id) => /sonnet-4[.-]5/i.test(id)) ?? + sonnets.find((id) => /sonnet-4(?![.-]?\d)/i.test(id)) ?? + sonnets[0] + ) +} + +export const ZooGateway = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: ZooGatewayProps) => { + const { t } = useAppTranslation() + const { zooCodeIsAuthenticated, zooCodeUserEmail, zooCodeUserName, zooCodeBaseUrl, uriScheme, deviceName } = + useExtensionState() + + const authUrl = getZooCodeAuthUrl(uriScheme, zooCodeBaseUrl, deviceName) + const resolvedDashboardBase = zooCodeBaseUrl?.replace(/\/$/, "") || "https://www.zoocode.dev" + + const zooModels = useMemo(() => routerModels?.["zoo-gateway"] ?? {}, [routerModels]) + const modelIds = useMemo(() => Object.keys(zooModels), [zooModels]) + const resolvedDefaultModelId = useMemo(() => pickZooGatewayDefaultModelId(modelIds), [modelIds]) + + useEffect(() => { + if (modelIds.length === 0) { + return + } + + const current = apiConfiguration.zooGatewayModelId + if (!current || !modelIds.includes(current)) { + setApiConfigurationField("zooGatewayModelId", resolvedDefaultModelId) + } + }, [apiConfiguration.zooGatewayModelId, modelIds, resolvedDefaultModelId, setApiConfigurationField]) + + return ( + <> +
+
+ + {zooCodeIsAuthenticated && zooCodeUserEmail && ( + {zooCodeUserEmail} + )} +
+ {!zooCodeIsAuthenticated ? ( +
+ +

+ {t("settings:providers.zooGateway.signInDescription")} +

+ + {t("settings:providers.zooGateway.signInButton")} + +
+ ) : ( +
+ + + {zooCodeUserName + ? t("settings:providers.zooGateway.authenticatedAs", { name: zooCodeUserName }) + : t("settings:providers.zooGateway.authenticated")} + +
+ )} +
+ + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx new file mode 100644 index 0000000000..9bdda1f433 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx @@ -0,0 +1,189 @@ +import React from "react" +import { render, screen, waitFor } from "@/utils/test-utils" +import type { ModelInfo, ProviderSettings, RouterModels } from "@roo-code/types" + +import { ZooGateway, pickZooGatewayDefaultModelId } from "../ZooGateway" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const extensionStateMock = { + zooCodeIsAuthenticated: true, + zooCodeUserEmail: "user@example.com", + zooCodeUserName: "User", + zooCodeBaseUrl: "https://www.zoocode.dev", + uriScheme: "vscode", + deviceName: "Test Device", +} + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => extensionStateMock, +})) + +vi.mock("@src/oauth/urls", () => ({ + getZooCodeAuthUrl: () => "https://www.zoocode.dev/dashboard/connect", +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ defaultModelId }: { defaultModelId: string }) => ( +
+ ), +})) + +const baseInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 1, + outputPrice: 2, +} + +function buildRouterModels(modelIds: string[]): RouterModels { + const models = Object.fromEntries(modelIds.map((id) => [id, baseInfo])) + return { "zoo-gateway": models } as unknown as RouterModels +} + +describe("pickZooGatewayDefaultModelId", () => { + it("falls back to the static default when the catalog is empty", () => { + expect(pickZooGatewayDefaultModelId([])).toBe("anthropic/claude-sonnet-4") + }) + + it("prefers an exact anthropic/claude-sonnet-4.5 match", () => { + const result = pickZooGatewayDefaultModelId([ + "anthropic/claude-sonnet-4", + "anthropic/claude-sonnet-4.5", + "openai/gpt-4o", + ]) + expect(result).toBe("anthropic/claude-sonnet-4.5") + }) + + it("matches a Bedrock-style claude-sonnet-4-5 id", () => { + const result = pickZooGatewayDefaultModelId([ + "anthropic.claude-sonnet-4-20250514-v1:0", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + ]) + expect(result).toBe("anthropic.claude-sonnet-4-5-20250929-v1:0") + }) + + it("falls back to claude sonnet 4 when 4.5 is not in the catalog", () => { + const result = pickZooGatewayDefaultModelId(["openai/gpt-4o", "anthropic/claude-sonnet-4"]) + expect(result).toBe("anthropic/claude-sonnet-4") + }) + + it("falls back to the first available id when no claude sonnet is present", () => { + const result = pickZooGatewayDefaultModelId(["openai/gpt-4o", "google/gemini-2.5-pro"]) + expect(result).toBe("openai/gpt-4o") + }) +}) + +describe("ZooGateway component", () => { + const baseProps = { + organizationAllowList: { allowAll: true, providers: {} } as ProviderSettings extends never ? never : any, + setApiConfigurationField: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("auto-selects the resolved default model when the profile has no model id", async () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(setApiConfigurationField).toHaveBeenCalledWith("zooGatewayModelId", "anthropic/claude-sonnet-4.5") + }) + }) + + it("reassigns a stale model id that is not in the catalog", async () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(setApiConfigurationField).toHaveBeenCalledWith( + "zooGatewayModelId", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + ) + }) + }) + + it("does not overwrite a model id that is already valid for the catalog", async () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + }) + + it("does nothing while the catalog is still empty (router models loading)", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + + it("renders the sign-in validation error inline when not authenticated", () => { + const original = extensionStateMock.zooCodeIsAuthenticated + extensionStateMock.zooCodeIsAuthenticated = false + try { + render( + , + ) + + expect(screen.getByText("settings:validation.zooGatewaySignIn")).toBeInTheDocument() + } finally { + extensionStateMock.zooCodeIsAuthenticated = original + } + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 1e10979c63..d5dd0d0ded 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -23,6 +23,7 @@ export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" +export { ZooGateway } from "./ZooGateway" export { MiniMax } from "./MiniMax" export { Mimo } from "./Mimo" export { Baseten } from "./Baseten" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index e7eefacb01..9cc9dafa01 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -6,7 +6,9 @@ import { moonshotDefaultModelId, geminiDefaultModelId, mistralDefaultModelId, + openRouterDefaultModelId, openAiNativeDefaultModelId, + openAiCodexDefaultModelId, qwenCodeDefaultModelId, vertexDefaultModelId, xaiDefaultModelId, @@ -17,6 +19,13 @@ import { minimaxDefaultModelId, basetenDefaultModelId, mimoDefaultModelId, + poeDefaultModelId, + requestyDefaultModelId, + unboundDefaultModelId, + litellmDefaultModelId, + vercelAiGatewayDefaultModelId, + opencodeGoDefaultModelId, + zooGatewayDefaultModelId, } from "@roo-code/types" import { MODELS_BY_PROVIDER } from "../constants" @@ -85,6 +94,68 @@ export const getDefaultModelIdForProvider = (provider: ProviderName, apiConfigur return PROVIDER_DEFAULT_MODEL_IDS[provider] ?? "" } +export type ProviderModelConfig = { + field: keyof ProviderSettings + default?: string +} + +// Minimal per-provider config used by ApiOptions for model-id field wiring. +// Kept in this file to keep ApiOptions.tsx from growing a second registry. +const PROVIDER_MODEL_CONFIG: Partial> = { + openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, + requesty: { field: "requestyModelId", default: requestyDefaultModelId }, + unbound: { field: "unboundModelId", default: unboundDefaultModelId }, + litellm: { field: "litellmModelId", default: litellmDefaultModelId }, + anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, + "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, + "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, + "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, + gemini: { field: "apiModelId", default: geminiDefaultModelId }, + deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, + moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, + minimax: { field: "apiModelId", default: minimaxDefaultModelId }, + mimo: { field: "apiModelId", default: mimoDefaultModelId }, + mistral: { field: "apiModelId", default: mistralDefaultModelId }, + xai: { field: "apiModelId", default: xaiDefaultModelId }, + baseten: { field: "apiModelId", default: basetenDefaultModelId }, + bedrock: { field: "apiModelId", default: bedrockDefaultModelId }, + vertex: { field: "apiModelId", default: vertexDefaultModelId }, + sambanova: { field: "apiModelId", default: sambaNovaDefaultModelId }, + zai: { field: "apiModelId" }, + fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, + poe: { field: "apiModelId", default: poeDefaultModelId }, + "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, + "opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, + "zoo-gateway": { field: "zooGatewayModelId", default: zooGatewayDefaultModelId }, + openai: { field: "openAiModelId" }, + ollama: { field: "ollamaModelId" }, + lmstudio: { field: "lmStudioModelId" }, +} + +export function getProviderModelConfig(provider: string, apiConfiguration?: ProviderSettings) { + const config = PROVIDER_MODEL_CONFIG[provider as ProviderName] + if (!config) return undefined + + if (provider === "zai") { + return { + ...config, + default: getDefaultModelIdForProvider(provider as ProviderName, apiConfiguration), + } + } + + return config +} + +// Custom mapping for doc URL slugs. Default is provider key. +const PROVIDER_DOCS_SLUGS: Partial> = { + "openai-native": "openai", + openai: "openai-compatible", +} + +export function getProviderDocsSlug(provider: string) { + return PROVIDER_DOCS_SLUGS[provider as ProviderName] ?? provider +} + export const getStaticModelsForProvider = ( provider: ProviderName, customArnLabel?: string, diff --git a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx index 7667d32ad6..fdde35320f 100644 --- a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx +++ b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx @@ -36,7 +36,8 @@ const getWelcomeApiConfiguration = (apiConfiguration?: ProviderSettings): Provid } const WelcomeViewProvider = () => { - const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme } = useExtensionState() + const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme, zooCodeIsAuthenticated } = + useExtensionState() const { t } = useAppTranslation() const [errorMessage, setErrorMessage] = useState(undefined) const [showProviderSetup, setShowProviderSetup] = useState(false) @@ -65,7 +66,7 @@ const WelcomeViewProvider = () => { return } - const error = validateApiConfiguration(effectiveApiConfiguration) + const error = validateApiConfiguration(effectiveApiConfiguration, undefined, undefined, zooCodeIsAuthenticated) if (error) { setErrorMessage(error) @@ -78,7 +79,14 @@ const WelcomeViewProvider = () => { text: currentApiConfigName, apiConfiguration: effectiveApiConfiguration, }) - }, [showProviderSetup, apiConfiguration, setApiConfiguration, effectiveApiConfiguration, currentApiConfigName]) + }, [ + showProviderSetup, + apiConfiguration, + setApiConfiguration, + effectiveApiConfiguration, + currentApiConfigName, + zooCodeIsAuthenticated, + ]) if (!showProviderSetup) { return ( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f7e3bb69e2..e182578114 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -560,6 +560,13 @@ "placeholder": "Per defecte: claude", "maxTokensLabel": "Tokens màxims de sortida", "maxTokensDescription": "Nombre màxim de tokens de sortida per a les respostes de Claude Code. El valor per defecte és 8000." + }, + "zooGateway": { + "account": "Compte de Zoo Code", + "signInButton": "Iniciar sessió a Zoo Code", + "signInDescription": "Inicia sessió per utilitzar Zoo Gateway amb el teu compte", + "authenticated": "Autenticat", + "authenticatedAs": "Autenticat com a {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "El proveïdor '{{provider}}' no està permès per la vostra organització", "modelNotAllowed": "El model '{{model}}' no està permès per al proveïdor '{{provider}}' per la vostra organització", "profileInvalid": "Aquest perfil conté un proveïdor o model que no està permès per la vostra organització", - "qwenCodeOauthPath": "Has de proporcionar una ruta vàlida de credencials OAuth" + "qwenCodeOauthPath": "Has de proporcionar una ruta vàlida de credencials OAuth", + "zooGatewaySignIn": "Inicia sessió a Zoo Code per utilitzar Zoo Gateway." }, "placeholders": { "apiKey": "Introduïu la clau API...", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 1aa9f96053..4fc253c34b 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -560,6 +560,13 @@ "placeholder": "Standard: claude", "maxTokensLabel": "Maximale Ausgabe-Tokens", "maxTokensDescription": "Maximale Anzahl an Ausgabe-Tokens für Claude Code-Antworten. Standard ist 8000." + }, + "zooGateway": { + "account": "Zoo Code Konto", + "signInButton": "Bei Zoo Code anmelden", + "signInDescription": "Melde dich an, um Zoo Gateway mit deinem Konto zu nutzen", + "authenticated": "Authentifiziert", + "authenticatedAs": "Authentifiziert als {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Anbieter '{{provider}}' ist von deiner Organisation nicht erlaubt", "modelNotAllowed": "Modell '{{model}}' ist für Anbieter '{{provider}}' von deiner Organisation nicht erlaubt", "profileInvalid": "Dieses Profil enthält einen Anbieter oder ein Modell, das von deiner Organisation nicht erlaubt ist", - "qwenCodeOauthPath": "Du musst einen gültigen OAuth-Anmeldedaten-Pfad angeben" + "qwenCodeOauthPath": "Du musst einen gültigen OAuth-Anmeldedaten-Pfad angeben", + "zooGatewaySignIn": "Melde dich bei Zoo Code an, um Zoo Gateway zu verwenden." }, "placeholders": { "apiKey": "API-Schlüssel eingeben...", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7f3527df6b..faacde3980 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -553,6 +553,13 @@ "learnMore": "Learn more about provider routing" } }, + "zooGateway": { + "account": "Zoo Code Account", + "signInButton": "Sign in to Zoo Code", + "signInDescription": "Sign in to use Zoo Gateway with your account", + "authenticated": "Authenticated", + "authenticatedAs": "Authenticated as {{name}}" + }, "customModel": { "capabilities": "Configure the capabilities and pricing for your custom OpenAI-compatible model. Be careful when specifying the model capabilities, as they can affect how Zoo Code performs.", "maxTokens": { @@ -969,7 +976,8 @@ "providerNotAllowed": "Provider '{{provider}}' is not allowed by your organization", "modelNotAllowed": "Model '{{model}}' is not allowed for provider '{{provider}}' by your organization", "profileInvalid": "This profile contains a provider or model that is not allowed by your organization", - "qwenCodeOauthPath": "You must provide a valid OAuth credentials path." + "qwenCodeOauthPath": "You must provide a valid OAuth credentials path.", + "zooGatewaySignIn": "Sign in to Zoo Code to use Zoo Gateway." }, "placeholders": { "apiKey": "Enter API Key...", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 154319baef..a365284878 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -560,6 +560,13 @@ "placeholder": "Por defecto: claude", "maxTokensLabel": "Tokens máximos de salida", "maxTokensDescription": "Número máximo de tokens de salida para las respuestas de Claude Code. El valor predeterminado es 8000." + }, + "zooGateway": { + "account": "Cuenta de Zoo Code", + "signInButton": "Iniciar sesión en Zoo Code", + "signInDescription": "Inicia sesión para usar Zoo Gateway con tu cuenta", + "authenticated": "Autenticado", + "authenticatedAs": "Autenticado como {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "El proveedor '{{provider}}' no está permitido por su organización", "modelNotAllowed": "El modelo '{{model}}' no está permitido para el proveedor '{{provider}}' por su organización", "profileInvalid": "Este perfil contiene un proveedor o modelo que no está permitido por su organización", - "qwenCodeOauthPath": "Debes proporcionar una ruta válida de credenciales OAuth" + "qwenCodeOauthPath": "Debes proporcionar una ruta válida de credenciales OAuth", + "zooGatewaySignIn": "Inicia sesión en Zoo Code para usar Zoo Gateway." }, "placeholders": { "apiKey": "Ingrese clave API...", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 30cdd37785..fa946b9435 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -560,6 +560,13 @@ "placeholder": "Défaut : claude", "maxTokensLabel": "Jetons de sortie max", "maxTokensDescription": "Nombre maximum de jetons de sortie pour les réponses de Claude Code. La valeur par défaut est 8000." + }, + "zooGateway": { + "account": "Compte Zoo Code", + "signInButton": "Se connecter à Zoo Code", + "signInDescription": "Connectez-vous pour utiliser Zoo Gateway avec votre compte", + "authenticated": "Authentifié", + "authenticatedAs": "Authentifié en tant que {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Le fournisseur '{{provider}}' n'est pas autorisé par votre organisation", "modelNotAllowed": "Le modèle '{{model}}' n'est pas autorisé pour le fournisseur '{{provider}}' par votre organisation", "profileInvalid": "Ce profil contient un fournisseur ou un modèle qui n'est pas autorisé par votre organisation", - "qwenCodeOauthPath": "Tu dois fournir un chemin valide pour les identifiants OAuth" + "qwenCodeOauthPath": "Tu dois fournir un chemin valide pour les identifiants OAuth", + "zooGatewaySignIn": "Connectez-vous à Zoo Code pour utiliser Zoo Gateway." }, "placeholders": { "apiKey": "Saisissez la clé API...", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a334b8cb5f..2501871e0b 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -560,6 +560,13 @@ "placeholder": "डिफ़ॉल्ट: claude", "maxTokensLabel": "अधिकतम आउटपुट टोकन", "maxTokensDescription": "Claude Code प्रतिक्रियाओं के लिए आउटपुट टोकन की अधिकतम संख्या। डिफ़ॉल्ट 8000 है।" + }, + "zooGateway": { + "account": "Zoo Code खाता", + "signInButton": "Zoo Code में साइन इन करें", + "signInDescription": "अपने खाते के साथ Zoo Gateway का उपयोग करने के लिए साइन इन करें", + "authenticated": "प्रमाणित", + "authenticatedAs": "{{name}} के रूप में प्रमाणित" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "प्रदाता '{{provider}}' आपके संगठन द्वारा अनुमत नहीं है", "modelNotAllowed": "मॉडल '{{model}}' प्रदाता '{{provider}}' के लिए आपके संगठन द्वारा अनुमत नहीं है", "profileInvalid": "इस प्रोफ़ाइल में एक प्रदाता या मॉडल शामिल है जो आपके संगठन द्वारा अनुमत नहीं है", - "qwenCodeOauthPath": "आपको एक वैध OAuth क्रेडेंशियल पथ प्रदान करना होगा" + "qwenCodeOauthPath": "आपको एक वैध OAuth क्रेडेंशियल पथ प्रदान करना होगा", + "zooGatewaySignIn": "Zoo Gateway का उपयोग करने के लिए Zoo Code में साइन इन करें।" }, "placeholders": { "apiKey": "API कुंजी दर्ज करें...", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 23e974429e..ee183e93af 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -560,6 +560,13 @@ "placeholder": "Default: claude", "maxTokensLabel": "Token Output Maks", "maxTokensDescription": "Jumlah maksimum token output untuk respons Claude Code. Default adalah 8000." + }, + "zooGateway": { + "account": "Akun Zoo Code", + "signInButton": "Masuk ke Zoo Code", + "signInDescription": "Masuk untuk menggunakan Zoo Gateway dengan akun Anda", + "authenticated": "Terautentikasi", + "authenticatedAs": "Terautentikasi sebagai {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Provider '{{provider}}' tidak diizinkan oleh organisasi kamu", "modelNotAllowed": "Model '{{model}}' tidak diizinkan untuk provider '{{provider}}' oleh organisasi kamu", "profileInvalid": "Profil ini berisi provider atau model yang tidak diizinkan oleh organisasi kamu", - "qwenCodeOauthPath": "Kamu harus memberikan jalur kredensial OAuth yang valid" + "qwenCodeOauthPath": "Kamu harus memberikan jalur kredensial OAuth yang valid", + "zooGatewaySignIn": "Masuk ke Zoo Code untuk menggunakan Zoo Gateway." }, "placeholders": { "apiKey": "Masukkan API Key...", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 3015d9338d..2e358e5d96 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -560,6 +560,13 @@ "placeholder": "Predefinito: claude", "maxTokensLabel": "Token di output massimi", "maxTokensDescription": "Numero massimo di token di output per le risposte di Claude Code. Il valore predefinito è 8000." + }, + "zooGateway": { + "account": "Account Zoo Code", + "signInButton": "Accedi a Zoo Code", + "signInDescription": "Accedi per utilizzare Zoo Gateway con il tuo account", + "authenticated": "Autenticato", + "authenticatedAs": "Autenticato come {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Il fornitore '{{provider}}' non è consentito dalla tua organizzazione", "modelNotAllowed": "Il modello '{{model}}' non è consentito per il fornitore '{{provider}}' dalla tua organizzazione.", "profileInvalid": "Questo profilo contiene un fornitore o un modello non consentito dalla tua organizzazione.", - "qwenCodeOauthPath": "Devi fornire un percorso valido per le credenziali OAuth" + "qwenCodeOauthPath": "Devi fornire un percorso valido per le credenziali OAuth", + "zooGatewaySignIn": "Accedi a Zoo Code per utilizzare Zoo Gateway." }, "placeholders": { "apiKey": "Inserisci chiave API...", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 02091510e5..e6b59fdf55 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -560,6 +560,13 @@ "placeholder": "デフォルト:claude", "maxTokensLabel": "最大出力トークン", "maxTokensDescription": "Claude Codeレスポンスの最大出力トークン数。デフォルトは8000です。" + }, + "zooGateway": { + "account": "Zoo Code アカウント", + "signInButton": "Zoo Code にサインイン", + "signInDescription": "Zoo Gateway をアカウントで使用するにはサインインしてください", + "authenticated": "認証済み", + "authenticatedAs": "{{name}} として認証済み" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "プロバイダー「{{provider}}」は組織によって許可されていません", "modelNotAllowed": "モデル「{{model}}」はプロバイダー「{{provider}}」に対して組織によって許可されていません", "profileInvalid": "このプロファイルには、組織によって許可されていないプロバイダーまたはモデルが含まれています", - "qwenCodeOauthPath": "有効なOAuth認証情報のパスを提供する必要があります" + "qwenCodeOauthPath": "有効なOAuth認証情報のパスを提供する必要があります", + "zooGatewaySignIn": "Zoo Gatewayを使用するにはZoo Codeにサインインしてください。" }, "placeholders": { "apiKey": "API キーを入力...", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 4a2155aba4..58c4e34828 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -560,6 +560,13 @@ "placeholder": "기본값: claude", "maxTokensLabel": "최대 출력 토큰", "maxTokensDescription": "Claude Code 응답의 최대 출력 토큰 수. 기본값은 8000입니다." + }, + "zooGateway": { + "account": "Zoo Code 계정", + "signInButton": "Zoo Code에 로그인", + "signInDescription": "계정으로 Zoo Gateway를 사용하려면 로그인하세요", + "authenticated": "인증됨", + "authenticatedAs": "{{name}}(으)로 인증됨" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "제공자 '{{provider}}'는 조직에서 허용되지 않습니다", "modelNotAllowed": "모델 '{{model}}'은 제공자 '{{provider}}'에 대해 조직에서 허용되지 않습니다", "profileInvalid": "이 프로필에는 조직에서 허용되지 않는 제공자 또는 모델이 포함되어 있습니다", - "qwenCodeOauthPath": "유효한 OAuth 자격 증명 경로를 제공해야 합니다" + "qwenCodeOauthPath": "유효한 OAuth 자격 증명 경로를 제공해야 합니다", + "zooGatewaySignIn": "Zoo Gateway를 사용하려면 Zoo Code에 로그인하세요." }, "placeholders": { "apiKey": "API 키 입력...", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 006fd8721f..2e98740174 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -560,6 +560,13 @@ "placeholder": "Standaard: claude", "maxTokensLabel": "Max Output Tokens", "maxTokensDescription": "Maximaal aantal output-tokens voor Claude Code-reacties. Standaard is 8000." + }, + "zooGateway": { + "account": "Zoo Code Account", + "signInButton": "Inloggen bij Zoo Code", + "signInDescription": "Log in om Zoo Gateway met je account te gebruiken", + "authenticated": "Geauthenticeerd", + "authenticatedAs": "Geauthenticeerd als {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Provider '{{provider}}' is niet toegestaan door je organisatie", "modelNotAllowed": "Model '{{model}}' is niet toegestaan voor provider '{{provider}}' door je organisatie", "profileInvalid": "Dit profiel bevat een provider of model dat niet is toegestaan door je organisatie", - "qwenCodeOauthPath": "Je moet een geldig OAuth-referentiepad opgeven" + "qwenCodeOauthPath": "Je moet een geldig OAuth-referentiepad opgeven", + "zooGatewaySignIn": "Log in bij Zoo Code om Zoo Gateway te gebruiken." }, "placeholders": { "apiKey": "Voer API-sleutel in...", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c4492da87c..c9ee66c354 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -560,6 +560,13 @@ "placeholder": "Domyślnie: claude", "maxTokensLabel": "Maksymalna liczba tokenów wyjściowych", "maxTokensDescription": "Maksymalna liczba tokenów wyjściowych dla odpowiedzi Claude Code. Domyślnie 8000." + }, + "zooGateway": { + "account": "Konto Zoo Code", + "signInButton": "Zaloguj się do Zoo Code", + "signInDescription": "Zaloguj się, aby korzystać z Zoo Gateway ze swoim kontem", + "authenticated": "Uwierzytelniono", + "authenticatedAs": "Uwierzytelniono jako {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Dostawca '{{provider}}' nie jest dozwolony przez Twoją organizację", "modelNotAllowed": "Model '{{model}}' nie jest dozwolony dla dostawcy '{{provider}}' przez Twoją organizację", "profileInvalid": "Ten profil zawiera dostawcę lub model, który nie jest dozwolony przez Twoją organizację", - "qwenCodeOauthPath": "Musisz podać prawidłową ścieżkę do poświadczeń OAuth" + "qwenCodeOauthPath": "Musisz podać prawidłową ścieżkę do poświadczeń OAuth", + "zooGatewaySignIn": "Zaloguj się do Zoo Code, aby korzystać z Zoo Gateway." }, "placeholders": { "apiKey": "Wprowadź klucz API...", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 6e19325c5c..de4d8550a6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -560,6 +560,13 @@ "placeholder": "Padrão: claude", "maxTokensLabel": "Tokens de saída máximos", "maxTokensDescription": "Número máximo de tokens de saída para respostas do Claude Code. O padrão é 8000." + }, + "zooGateway": { + "account": "Conta Zoo Code", + "signInButton": "Entrar no Zoo Code", + "signInDescription": "Entre para usar o Zoo Gateway com sua conta", + "authenticated": "Autenticado", + "authenticatedAs": "Autenticado como {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "O provedor '{{provider}}' não é permitido pela sua organização", "modelNotAllowed": "O modelo '{{model}}' não é permitido para o provedor '{{provider}}' pela sua organização", "profileInvalid": "Este perfil contém um provedor ou modelo que não é permitido pela sua organização", - "qwenCodeOauthPath": "Você deve fornecer um caminho válido de credenciais OAuth" + "qwenCodeOauthPath": "Você deve fornecer um caminho válido de credenciais OAuth", + "zooGatewaySignIn": "Faça login no Zoo Code para usar o Zoo Gateway." }, "placeholders": { "apiKey": "Digite a chave API...", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2641939b9b..67baa866d4 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -560,6 +560,13 @@ "placeholder": "По умолчанию: claude", "maxTokensLabel": "Макс. выходных токенов", "maxTokensDescription": "Максимальное количество выходных токенов для ответов Claude Code. По умолчанию 8000." + }, + "zooGateway": { + "account": "Учетная запись Zoo Code", + "signInButton": "Войти в Zoo Code", + "signInDescription": "Войдите, чтобы использовать Zoo Gateway с вашей учетной записью", + "authenticated": "Аутентифицирован", + "authenticatedAs": "Аутентифицирован как {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Провайдер '{{provider}}' не разрешен вашей организацией", "modelNotAllowed": "Модель '{{model}}' не разрешена для провайдера '{{provider}}' вашей организацией", "profileInvalid": "Этот профиль содержит провайдера или модель, которые не разрешены вашей организацией", - "qwenCodeOauthPath": "Вы должны указать допустимый путь к учетным данным OAuth" + "qwenCodeOauthPath": "Вы должны указать допустимый путь к учетным данным OAuth", + "zooGatewaySignIn": "Войдите в Zoo Code, чтобы использовать Zoo Gateway." }, "placeholders": { "apiKey": "Введите API-ключ...", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2ed19417e7..6196041862 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -560,6 +560,13 @@ "placeholder": "Varsayılan: claude", "maxTokensLabel": "Maksimum Çıktı Token sayısı", "maxTokensDescription": "Claude Code yanıtları için maksimum çıktı token sayısı. Varsayılan 8000'dir." + }, + "zooGateway": { + "account": "Zoo Code Hesabı", + "signInButton": "Zoo Code'a giriş yap", + "signInDescription": "Hesabınızla Zoo Gateway kullanmak için giriş yapın", + "authenticated": "Kimlik doğrulandı", + "authenticatedAs": "{{name}} olarak kimlik doğrulandı" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Sağlayıcı '{{provider}}' kuruluşunuz tarafından izin verilmiyor", "modelNotAllowed": "Model '{{model}}' sağlayıcı '{{provider}}' için kuruluşunuz tarafından izin verilmiyor", "profileInvalid": "Bu profil, kuruluşunuz tarafından izin verilmeyen bir sağlayıcı veya model içeriyor", - "qwenCodeOauthPath": "Geçerli bir OAuth kimlik bilgileri yolu sağlamalısın" + "qwenCodeOauthPath": "Geçerli bir OAuth kimlik bilgileri yolu sağlamalısınız", + "zooGatewaySignIn": "Zoo Gateway'i kullanmak için Zoo Code'a giriş yapın." }, "placeholders": { "apiKey": "API anahtarını girin...", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e32beeff3e..e84f6b9619 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -560,6 +560,13 @@ "placeholder": "Mặc định: claude", "maxTokensLabel": "Số token đầu ra tối đa", "maxTokensDescription": "Số lượng token đầu ra tối đa cho các phản hồi của Claude Code. Mặc định là 8000." + }, + "zooGateway": { + "account": "Tài khoản Zoo Code", + "signInButton": "Đăng nhập vào Zoo Code", + "signInDescription": "Đăng nhập để sử dụng Zoo Gateway với tài khoản của bạn", + "authenticated": "Đã xác thực", + "authenticatedAs": "Đã xác thực với tên {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "Nhà cung cấp '{{provider}}' không được phép bởi tổ chức của bạn", "modelNotAllowed": "Mô hình '{{model}}' không được phép cho nhà cung cấp '{{provider}}' bởi tổ chức của bạn", "profileInvalid": "Hồ sơ này chứa một nhà cung cấp hoặc mô hình không được phép bởi tổ chức của bạn", - "qwenCodeOauthPath": "Bạn phải cung cấp đường dẫn thông tin xác thực OAuth hợp lệ" + "qwenCodeOauthPath": "Bạn phải cung cấp đường dẫn thông tin xác thực OAuth hợp lệ", + "zooGatewaySignIn": "Đăng nhập vào Zoo Code để sử dụng Zoo Gateway." }, "placeholders": { "apiKey": "Nhập khóa API...", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index fb67bb89c0..8f3c137329 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -560,6 +560,13 @@ "placeholder": "默认:claude", "maxTokensLabel": "最大输出 Token", "maxTokensDescription": "Claude Code 响应的最大输出 Token 数量。默认为 8000。" + }, + "zooGateway": { + "account": "Zoo Code 账户", + "signInButton": "登录 Zoo Code", + "signInDescription": "登录以使用您的账户访问 Zoo Gateway", + "authenticated": "已认证", + "authenticatedAs": "已认证为 {{name}}" } }, "checkpoints": { @@ -901,7 +908,8 @@ "providerNotAllowed": "提供商 '{{provider}}' 不允许用于您的组织", "modelNotAllowed": "模型 '{{model}}' 不允许用于提供商 '{{provider}}',您的组织不允许", "profileInvalid": "此配置文件包含您的组织不允许的提供商或模型", - "qwenCodeOauthPath": "您必须提供有效的 OAuth 凭证路径" + "qwenCodeOauthPath": "您必须提供有效的 OAuth 凭证路径", + "zooGatewaySignIn": "请登录 Zoo Code 以使用 Zoo Gateway。" }, "placeholders": { "apiKey": "请输入 API 密钥...", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index f0d4725cd2..1ebaff0e53 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -575,6 +575,13 @@ "placeholder": "預設:claude", "maxTokensLabel": "最大輸出 Token", "maxTokensDescription": "Claude Code 回應的最大輸出 Token 數量。預設為 8000。" + }, + "zooGateway": { + "account": "Zoo Code 帳戶", + "signInButton": "登入 Zoo Code", + "signInDescription": "登入以使用您的帳戶存取 Zoo Gateway", + "authenticated": "已認證", + "authenticatedAs": "已認證為 {{name}}" } }, "checkpoints": { @@ -916,7 +923,8 @@ "providerNotAllowed": "供應商 '{{provider}}' 不允許用於您的組織。", "modelNotAllowed": "模型 '{{model}}' 不允許用於供應商 '{{provider}}',此設定已被組織禁止", "profileInvalid": "此設定檔包含您的組織不允許的供應商或模型", - "qwenCodeOauthPath": "您必須提供有效的 OAuth 憑證路徑" + "qwenCodeOauthPath": "您必須提供有效的 OAuth 憑證路徑", + "zooGatewaySignIn": "請登入 Zoo Code 以使用 Zoo Gateway。" }, "placeholders": { "apiKey": "請輸入 API 金鑰...", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index ce4b7a7173..5d4f54b927 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -16,7 +16,12 @@ vi.mock("i18next", () => ({ }, })) -import { getModelValidationError, validateApiConfigurationExcludingModelErrors, validateBedrockArn } from "../validate" +import { + getModelValidationError, + validateApiConfiguration, + validateApiConfigurationExcludingModelErrors, + validateBedrockArn, +} from "../validate" describe("Model Validation Functions", () => { const mockRouterModels: RouterModels = { @@ -211,6 +216,96 @@ describe("Model Validation Functions", () => { expect(result).toBe("settings:validation.modelId") }) }) + + describe("Zoo Gateway validation", () => { + describe("validateApiConfiguration (welcome-view entry point)", () => { + it("returns a sign-in error when neither profile token nor Zoo auth is present", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + } + + const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false) + expect(result).toBe("settings:validation.zooGatewaySignIn") + }) + + it("returns undefined when Zoo Code auth is active without a profile token", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + } + + const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, true) + expect(result).toBeUndefined() + }) + + it("returns undefined when a profile session token is set", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + zooSessionToken: "zoo_ext_test_token", + } + + const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false) + expect(result).toBeUndefined() + }) + }) + + describe("validateApiConfigurationExcludingModelErrors (settings form)", () => { + // The settings form short-circuits zoo-gateway and renders the sign-in + // error inline in `ZooGateway.tsx`, so this entry point must never + // surface a zoo-gateway-specific error regardless of auth state. + it("returns undefined for zoo-gateway when unauthenticated and no token", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + } + + const result = validateApiConfigurationExcludingModelErrors( + config, + mockRouterModels, + allowAllOrganization, + ) + expect(result).toBeUndefined() + }) + + it("returns undefined for zoo-gateway when a profile token is set", () => { + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + zooSessionToken: "zoo_ext_test_token", + } + + const result = validateApiConfigurationExcludingModelErrors( + config, + mockRouterModels, + allowAllOrganization, + ) + expect(result).toBeUndefined() + }) + + it("surfaces PROVIDER_NOT_ALLOWED for zoo-gateway when organization disallows it", () => { + const orgWithoutZooGateway: OrganizationAllowList = { + allowAll: false, + providers: { + openrouter: { allowAll: true }, + }, + } + + const config: ProviderSettings = { + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + } + + const result = validateApiConfigurationExcludingModelErrors( + config, + mockRouterModels, + orgWithoutZooGateway, + ) + expect(result).toContain("settings:validation.providerNotAllowed") + }) + }) + }) }) describe("validateBedrockArn", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 0e6e1c6b9d..3de6480802 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -17,8 +17,9 @@ export function validateApiConfiguration( apiConfiguration: ProviderSettings, routerModels?: RouterModels, organizationAllowList?: OrganizationAllowList, + zooCodeIsAuthenticated?: boolean, ): string | undefined { - const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration) + const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, zooCodeIsAuthenticated) if (keysAndIdsPresentErrorMessage) { return keysAndIdsPresentErrorMessage @@ -36,7 +37,10 @@ export function validateApiConfiguration( return validateDynamicProviderModelId(apiConfiguration, routerModels) } -function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): string | undefined { +function validateModelsAndKeysProvided( + apiConfiguration: ProviderSettings, + zooCodeIsAuthenticated?: boolean, +): string | undefined { switch (apiConfiguration.apiProvider) { case "openrouter": if (!apiConfiguration.openRouterApiKey) { @@ -128,6 +132,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "zoo-gateway": + if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) { + return i18next.t("settings:validation.zooGatewaySignIn") + } + break case "baseten": if (!apiConfiguration.basetenApiKey) { return i18next.t("settings:validation.apiKey") @@ -282,16 +291,23 @@ export function getModelValidationError( * Validates API configuration but excludes model-specific errors. * This is used for the general API error display to prevent duplication * when model errors are shown in the model selector. + * + * Zoo Gateway's sign-in error is rendered inline by the `ZooGateway` provider + * component, so we skip the keys/sign-in check here. Organization provider + * restrictions still need to be enforced for zoo-gateway, so the org allowlist + * check below runs for every provider. */ export function validateApiConfigurationExcludingModelErrors( apiConfiguration: ProviderSettings, _routerModels?: RouterModels, // Keeping this for compatibility with the old function. organizationAllowList?: OrganizationAllowList, ): string | undefined { - const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration) + if (apiConfiguration.apiProvider !== "zoo-gateway") { + const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration) - if (keysAndIdsPresentErrorMessage) { - return keysAndIdsPresentErrorMessage + if (keysAndIdsPresentErrorMessage) { + return keysAndIdsPresentErrorMessage + } } const organizationAllowListError = validateProviderAgainstOrganizationSettings( From 019d85752549218f89aa3084cbd6a6fdd47d995f Mon Sep 17 00:00:00 2001 From: Armando Vaquera Date: Tue, 2 Jun 2026 20:03:56 -0600 Subject: [PATCH 7/9] feat(terminal): add VS Code integrated-terminal shell override (#277) * feat(terminal): add inline terminal profile selection (#119) Add a 'terminalProfile' setting that lets users choose which VS Code terminal profile the inline terminal uses. On Windows the default cmd/PowerShell shell may use a non-UTF-8 code page (e.g. GBK) and garble output; selecting a UTF-8 profile such as Git Bash resolves this. The setting reuses VS Code's terminal profile concept: when set, the profile name is resolved against terminal.integrated.profiles. to derive shellPath/shellArgs for createTerminal. When empty/unset the default terminal behavior is preserved unchanged. Adds backend unit tests for profile resolution and a webview test for the settings dropdown wiring. * fix(test): relax spy types for overloaded VS Code APIs (#119) * fix(test): use ES import instead of require() in terminal profile spec (#119) * refactor(terminal): address review feedback on inline terminal profile (#119) - Route profile names through a dedicated allowlisted `requestTerminalProfiles` message instead of the generic `getVSCodeSetting` (which reads any key the webview supplies); the extension reads the profiles and returns only names. - Preserve the profile's `env` (sanitized to string/null; null unsets a var), merged onto the base env in createTerminal. - Clarify the setting copy (en + es) vs the 'Use Inline Terminal' description. - Add tests: updateSettings->setTerminalProfile bridge, resolveWebviewView startup hydration, and profile env preservation/sanitization. * fix(terminal): resolve profile path[] to first existing candidate (#119) VS Code selects the first terminal-profile path candidate that exists on disk; mirror that instead of always taking index 0, falling back to the first non-empty candidate when none exist. Addresses CodeRabbit review on #277. * fix(terminal): hide inline profile picker when inline execution is off The terminal-profile dropdown only affects inline execution, which is active when shell integration is disabled. It previously stayed visible and editable even when inline mode was off, where it has no effect. Guard it with terminalShellIntegrationDisabled (defaulting to shown, matching the checkbox), mirroring the inline-only settings below. Addresses PR #277 review (edelauna). * fix(terminal): address review feedback * refactor(terminal): scope profile picker to VS Code integrated terminal, filter source-only profiles * fix(terminal): scope profile config to user settings, filter shellArgs, fix PATH join * refactor(terminal): address review feedback on profile picker * refactor(terminal): address review feedback on profile picker * refactor(terminal): promote no_shell_integration payload to typed object * feat(terminal): replace pWaitFor with event-based shell integration wait; add cmd.exe fast-path * fix(terminal): retry via execa silently when shell integration fails before submission * fix(terminal): close idle terminals when profile changes * fix(terminal): skip ZDOTDIR injection with profile override; clear cached profile on picker open * test(e2e): smoke-test VS Code terminal profile override lifecycle * fix(terminal): avoid replaying commands after shell integration failure * fix(terminal): harden profile settings and cleanup * Revert "feat(terminal): replace pWaitFor with event-based shell integration wait; add cmd.exe fast-path" This reverts commit 40e17b15d5f0dc39661c0fdc7c87bf8db8a8f92b. * fix(TerminalProcess): warning even when shell opens --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: Elliott de Launay --- .../vscode-e2e/fixtures/terminal-profile.json | 32 + .../src/fixtures/terminal-profile.ts | 58 ++ apps/vscode-e2e/src/runTest.ts | 2 + .../src/suite/tools/terminal-profile.test.ts | 232 ++++++ packages/types/src/api.ts | 7 + packages/types/src/global-settings.ts | 1 + packages/types/src/vscode-extension-host.ts | 6 + src/core/tools/ExecuteCommandTool.ts | 91 ++- .../__tests__/executeCommandTool.spec.ts | 22 + src/core/webview/ClineProvider.ts | 5 + .../webview/__tests__/ClineProvider.spec.ts | 15 + .../__tests__/webviewMessageHandler.spec.ts | 129 ++++ src/core/webview/webviewMessageHandler.ts | 39 + src/extension.ts | 2 + .../__tests__/api-terminal-profile.spec.ts | 41 ++ src/extension/api.ts | 11 + src/integrations/terminal/BaseTerminal.ts | 26 +- src/integrations/terminal/Terminal.ts | 447 ++++++++++- src/integrations/terminal/TerminalProcess.ts | 136 ++-- src/integrations/terminal/TerminalRegistry.ts | 35 +- .../__tests__/TerminalProcess.spec.ts | 156 +++- .../TerminalProcessExec.bash.spec.ts | 3 +- .../__tests__/TerminalProcessExec.cmd.spec.ts | 1 + .../TerminalProcessExec.pwsh.spec.ts | 1 + .../__tests__/TerminalProfile.spec.ts | 694 ++++++++++++++++++ .../__tests__/TerminalRegistry.spec.ts | 91 ++- .../__tests__/streamUtils/bashStream.ts | 2 +- .../__tests__/streamUtils/cmdStream.ts | 2 +- .../__tests__/streamUtils/pwshStream.ts | 4 +- src/integrations/terminal/types.ts | 19 +- .../src/components/settings/SettingsView.tsx | 4 + .../components/settings/TerminalSettings.tsx | 154 +++- .../SettingsView.change-detection.spec.tsx | 1 + .../TerminalSettings.profile.spec.tsx | 231 ++++++ .../src/context/ExtensionStateContext.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 8 + webview-ui/src/i18n/locales/de/settings.json | 8 + webview-ui/src/i18n/locales/en/settings.json | 8 + webview-ui/src/i18n/locales/es/settings.json | 8 + webview-ui/src/i18n/locales/fr/settings.json | 8 + webview-ui/src/i18n/locales/hi/settings.json | 8 + webview-ui/src/i18n/locales/id/settings.json | 8 + webview-ui/src/i18n/locales/it/settings.json | 8 + webview-ui/src/i18n/locales/ja/settings.json | 8 + webview-ui/src/i18n/locales/ko/settings.json | 8 + webview-ui/src/i18n/locales/nl/settings.json | 8 + webview-ui/src/i18n/locales/pl/settings.json | 8 + .../src/i18n/locales/pt-BR/settings.json | 8 + webview-ui/src/i18n/locales/ru/settings.json | 8 + webview-ui/src/i18n/locales/tr/settings.json | 8 + webview-ui/src/i18n/locales/vi/settings.json | 8 + .../src/i18n/locales/zh-CN/settings.json | 8 + .../src/i18n/locales/zh-TW/settings.json | 8 + 53 files changed, 2687 insertions(+), 159 deletions(-) create mode 100644 apps/vscode-e2e/fixtures/terminal-profile.json create mode 100644 apps/vscode-e2e/src/fixtures/terminal-profile.ts create mode 100644 apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts create mode 100644 src/extension/__tests__/api-terminal-profile.spec.ts create mode 100644 src/integrations/terminal/__tests__/TerminalProfile.spec.ts create mode 100644 webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx diff --git a/apps/vscode-e2e/fixtures/terminal-profile.json b/apps/vscode-e2e/fixtures/terminal-profile.json new file mode 100644 index 0000000000..5fdb581133 --- /dev/null +++ b/apps/vscode-e2e/fixtures/terminal-profile.json @@ -0,0 +1,32 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "TERMINAL_PROFILE_E2E_OVERRIDE" + }, + "response": { + "toolCalls": [ + { + "name": "execute_command", + "arguments": "{\"command\":\"printf 'zoo-profile-override-ok\\\\n' > terminal-profile-e2e/terminal-profile-override.txt\"}", + "id": "call_terminal_profile_override_001" + } + ] + } + }, + { + "match": { + "userMessage": "TERMINAL_PROFILE_E2E_DEFAULT" + }, + "response": { + "toolCalls": [ + { + "name": "execute_command", + "arguments": "{\"command\":\"printf 'zoo-profile-default-ok\\\\n' > terminal-profile-e2e/terminal-profile-default.txt\"}", + "id": "call_terminal_profile_default_001" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/src/fixtures/terminal-profile.ts b/apps/vscode-e2e/src/fixtures/terminal-profile.ts new file mode 100644 index 0000000000..d71f4a572c --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/terminal-profile.ts @@ -0,0 +1,58 @@ +import { LLMock } from "@copilotkit/aimock" + +import { toolResultContains } from "./tool-result" + +type TerminalProfileToolCall = { + name: "execute_command" | "attempt_completion" + params: Record + id: string +} + +type TerminalProfileFixture = { + toolCallId: string + expected: string[] + toolCalls: TerminalProfileToolCall[] +} + +export function addTerminalProfileResultFixtures(mock: InstanceType) { + const fixtures: TerminalProfileFixture[] = [ + { + toolCallId: "call_terminal_profile_override_001", + expected: ["Exit code: 0"], + toolCalls: [ + { + name: "attempt_completion", + params: { result: "Ran the command using the Zoo E2E Bash profile override." }, + id: "call_terminal_profile_override_002", + }, + ], + }, + { + toolCallId: "call_terminal_profile_default_001", + expected: ["Exit code: 0"], + toolCalls: [ + { + name: "attempt_completion", + params: { result: "Ran the command using the default terminal profile." }, + id: "call_terminal_profile_default_002", + }, + ], + }, + ] + + for (const fixture of fixtures) { + mock.addFixture({ + match: { + toolCallId: fixture.toolCallId, + predicate: (req) => toolResultContains(req, fixture.toolCallId, fixture.expected), + }, + response: { + toolCalls: fixture.toolCalls.map((toolCall) => ({ + name: toolCall.name, + arguments: JSON.stringify(toolCall.params), + id: toolCall.id, + })), + }, + }) + } +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index f3afa51ea2..10dd29678c 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -7,6 +7,7 @@ import { LLMock } from "@copilotkit/aimock" import { addApplyDiffResultFixtures } from "./fixtures/apply-diff" import { addExecuteCommandResultFixtures } from "./fixtures/execute-command" +import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" @@ -108,6 +109,7 @@ async function main() { if (!isRecord) { addApplyDiffResultFixtures(mock) addExecuteCommandResultFixtures(mock) + addTerminalProfileResultFixtures(mock) addListFilesResultFixtures(mock) addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) diff --git a/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts b/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts new file mode 100644 index 0000000000..a23bd8163c --- /dev/null +++ b/apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts @@ -0,0 +1,232 @@ +/** + * Linux-only e2e smoke test for the VS Code terminal profile override. + * + * Proves that: + * 1. Setting a profile override causes commands to run through the selected + * VS Code integrated-terminal shell without a shell_integration_warning. + * 2. Clearing the override starts a fresh terminal on the next command. + * + * Windows profile coverage (cmd.exe fast-path, PowerShell) is proven by unit + * tests in src/integrations/terminal/__tests__/. This test requires /bin/bash + * which only exists on Linux/macOS. + */ +import * as assert from "assert" +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { sleep, waitUntilCompleted } from "../utils" +import { setDefaultSuiteTimeout } from "../test-utils" + +const TEST_DIR_NAME = "terminal-profile-e2e" +const OVERRIDE_FILE = "terminal-profile-override.txt" +const DEFAULT_FILE = "terminal-profile-default.txt" +const PROFILE_NAME = "Zoo E2E Bash" + +suite("Terminal Profile", function () { + if (process.platform !== "linux") { + return + } + + setDefaultSuiteTimeout(this) + + let workspaceDir: string + let testDir: string + let originalProfiles: Record | undefined + + suiteSetup(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "anthropic/claude-sonnet-4.5", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + }) + + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders?.length) throw new Error("No workspace folder found") + workspaceDir = workspaceFolders[0]!.uri.fsPath + testDir = path.join(workspaceDir, TEST_DIR_NAME) + await fs.rm(testDir, { recursive: true, force: true }) + await fs.mkdir(testDir, { recursive: true }) + + // Save the current global linux profiles so we can restore them in teardown. + originalProfiles = vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .inspect>("linux")?.globalValue + + // Write the test profile to VS Code user (global) settings. + // Terminal.getConfiguredProfiles() intentionally excludes workspace settings + // for security, so global scope is required here. + await vscode.workspace.getConfiguration("terminal.integrated.profiles").update( + "linux", + { + ...originalProfiles, + [PROFILE_NAME]: { path: "/bin/bash", args: ["--noprofile", "--norc"] }, + }, + vscode.ConfigurationTarget.Global, + ) + + // Activate the profile override in-process. api.setConfiguration() alone + // does not call Terminal.setTerminalProfile(), so this dedicated method is + // required to wire up the static in the running extension host. + globalThis.api.setTerminalProfile(PROFILE_NAME) + }) + + suiteTeardown(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + // Always restore — order matters: clear profile first so any subsequent + // terminal creation uses the default, then restore VS Code settings. + globalThis.api.setTerminalProfile(undefined) + + await vscode.workspace + .getConfiguration("terminal.integrated.profiles") + .update("linux", originalProfiles, vscode.ConfigurationTarget.Global) + + await fs.rm(testDir, { recursive: true, force: true }) + + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + }) + }) + + setup(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + await fs.rm(path.join(testDir, OVERRIDE_FILE), { force: true }) + await fs.rm(path.join(testDir, DEFAULT_FILE), { force: true }) + await sleep(100) + }) + + teardown(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // task may not be running + } + + await sleep(100) + }) + + test("executes command through profile override without shell integration warning", async function () { + const api = globalThis.api + const messages: ClineMessage[] = [] + + const messageHandler = ({ message }: { message: ClineMessage }) => { + messages.push(message) + } + api.on(RooCodeEventName.Message, messageHandler) + + try { + await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "code", + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + terminalShellIntegrationDisabled: false, + }, + text: "TERMINAL_PROFILE_E2E_OVERRIDE", + }), + timeout: 90_000, + }) + + const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning") + const gotError = messages.some((m) => m.type === "say" && m.say === "error") + + assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile") + assert.strictEqual( + gotError, + false, + `Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`, + ) + + const content = await fs.readFile(path.join(testDir, OVERRIDE_FILE), "utf-8") + assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`) + + assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist") + const profileTerminal = vscode.window.terminals.find((terminal) => { + const options = terminal.creationOptions as vscode.TerminalOptions + return ( + options.name === "Zoo Code" && + options.shellPath === "/bin/bash" && + Array.isArray(options.shellArgs) && + options.shellArgs.includes("--noprofile") && + options.shellArgs.includes("--norc") + ) + }) + assert.ok(profileTerminal, "Expected a Zoo Code terminal created with the configured Bash profile") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } + }) + + test("starts a fresh terminal after clearing the profile override", async function () { + const api = globalThis.api + const messages: ClineMessage[] = [] + + const messageHandler = ({ message }: { message: ClineMessage }) => { + messages.push(message) + } + api.on(RooCodeEventName.Message, messageHandler) + + try { + // Clear the override — this also calls TerminalRegistry.closeIdleTerminals() + // so the terminal from test 1 is disposed before this task runs. + api.setTerminalProfile(undefined) + await sleep(200) // let VS Code process the disposal before the next task + + await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "code", + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + terminalShellIntegrationDisabled: false, + }, + text: "TERMINAL_PROFILE_E2E_DEFAULT", + }), + timeout: 90_000, + }) + + const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning") + const gotError = messages.some((m) => m.type === "say" && m.say === "error") + + assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with the default profile") + assert.strictEqual( + gotError, + false, + `Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`, + ) + + const content = await fs.readFile(path.join(testDir, DEFAULT_FILE), "utf-8") + assert.ok(content.includes("zoo-profile-default-ok"), `Output file should contain marker, got: ${content}`) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + } + }) +}) diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index ad2130fb32..c9b89b3b00 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -139,6 +139,13 @@ export interface RooCodeAPI extends EventEmitter { * @throws Error if the profile does not exist */ setActiveProfile(name: string): Promise + /** + * Activates a process-wide VS Code terminal profile override for Zoo Code + * commands. This is intended for trusted extension integrations. + * Passing undefined restores the VS Code default profile behavior and + * closes idle terminals so the next command starts fresh. + */ + setTerminalProfile(name: string | undefined): void } export interface RooCodeIpcServer extends EventEmitter { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dd7c84cf2f..8399ae310e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({ terminalZshOhMy: z.boolean().optional(), terminalZshP10k: z.boolean().optional(), terminalZdotdir: z.boolean().optional(), + terminalProfile: z.string().optional(), execaShellPath: z.string().optional(), diagnosticsEnabled: z.boolean().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5853e536de..0f9e00ed99 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -63,6 +63,7 @@ export interface ExtensionMessage { | "commandExecutionStatus" | "mcpExecutionStatus" | "vsCodeSetting" + | "terminalProfiles" | "authenticatedUser" | "condenseTaskContextStarted" | "condenseTaskContextResponse" @@ -152,6 +153,8 @@ export interface ExtensionMessage { error?: string setting?: string value?: any // eslint-disable-line @typescript-eslint/no-explicit-any + /** Sanitized VS Code terminal profile names for the `terminalProfiles` message. */ + profiles?: string[] hasContent?: boolean items?: MarketplaceItem[] userInfo?: CloudUserInfo @@ -275,6 +278,7 @@ export type ExtensionState = Pick< | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" | "execaShellPath" | "diagnosticsEnabled" | "language" @@ -453,6 +457,8 @@ export interface WebviewMessage { | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" + | "requestTerminalProfiles" + | "openTerminalProfilePicker" | "updateCondensingPrompt" | "playSound" | "playTts" diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 8fcb917b13..16b5a37e3c 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -12,7 +12,14 @@ import { Task } from "../task/Task" import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" -import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types" +import { + ExitCodeDetails, + RooTerminalCallbacks, + RooTerminalProvider, + RooTerminalProcess, + ShellIntegrationError, + ShellIntegrationErrorDetails, +} from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" @@ -21,7 +28,21 @@ import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" -class ShellIntegrationError extends Error {} +export { ShellIntegrationError } from "../../integrations/terminal/types" + +export function canRetryShellIntegrationError(error: unknown): error is ShellIntegrationError { + return error instanceof ShellIntegrationError && !error.commandSubmitted +} + +export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): { + terminalProvider: RooTerminalProvider + isCmdExeFallback: boolean +} { + const isCmdExeFallback = !terminalShellIntegrationDisabled && Terminal.isActiveShellCmdExe() + const terminalProvider = terminalShellIntegrationDisabled || isCmdExeFallback ? "execa" : "vscode" + + return { terminalProvider, isCmdExeFallback } +} interface ExecuteCommandParams { command: string @@ -116,14 +137,14 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) } catch (error: unknown) { - const status: CommandExecutionStatus = { executionId, status: "fallback" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("shell_integration_warning") - // Invalidate pending ask from first execution to prevent race condition task.supersedePendingAsk() - if (error instanceof ShellIntegrationError) { + if (canRetryShellIntegrationError(error)) { + // Silent retry via execa — shell startup race, command was not submitted. + const status: CommandExecutionStatus = { executionId, status: "fallback" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + const [rejected, result] = await executeCommandInTerminal(task, { ...options, terminalShellIntegrationDisabled: true, @@ -135,7 +156,16 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) } else { - pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + // Command was submitted but shell integration lost track of it — show warning. + await task.say("shell_integration_warning") + + if (error instanceof ShellIntegrationError) { + pushToolResult( + "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", + ) + } else { + pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + } } } @@ -196,12 +226,19 @@ export async function executeCommandInTerminal( let result: string = "" let persistedResult: PersistedCommandOutput | undefined let exitDetails: ExitCodeDetails | undefined - let shellIntegrationError: string | undefined + let shellIntegrationError: ShellIntegrationError | undefined let hasAskedForCommandOutput = false - const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode" + const { terminalProvider, isCmdExeFallback } = getTerminalProviderForExecution(terminalShellIntegrationDisabled) const provider = await task.providerRef.deref() + // cmd.exe can't use shell integration — tell the webview to expand the output + // panel immediately (same effect as the retry-fallback path). + if (isCmdExeFallback) { + const status: CommandExecutionStatus = { executionId, status: "fallback" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + } + // Get global storage path for persisted output artifacts const globalStoragePath = provider?.context?.globalStorageUri?.fsPath let interceptor: OutputInterceptor | undefined @@ -360,9 +397,9 @@ export async function executeCommandInTerminal( } if (terminalProvider === "vscode") { - callbacks.onNoShellIntegration = async (error: string) => { + callbacks.onNoShellIntegration = async (details: ShellIntegrationErrorDetails) => { TelemetryService.instance.captureShellIntegrationError(task.taskId) - shellIntegrationError = error + shellIntegrationError = new ShellIntegrationError(details.message, details.commandSubmitted) } } @@ -442,7 +479,7 @@ export async function executeCommandInTerminal( } if (shellIntegrationError) { - throw new ShellIntegrationError(shellIntegrationError) + throw shellIntegrationError } // Wait for a short delay to ensure all messages are sent to the webview. @@ -482,31 +519,15 @@ export async function executeCommandInTerminal( return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)] } - // Use inline format for small outputs (original behavior with exit status) - let exitStatus: string = "" - - if (exitDetails !== undefined) { - if (exitDetails.signalName) { - exitStatus = `Process terminated by signal ${exitDetails.signalName}` - - if (exitDetails.coreDumpPossible) { - exitStatus += " - core dump possible" - } - } else if (exitDetails.exitCode === undefined) { - result += "" - exitStatus = `Exit code: ` - } else { - if (exitDetails.exitCode !== 0) { - exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n" - } - - exitStatus += `Exit code: ${exitDetails.exitCode}` - } - } else { + // Use inline format for small outputs (original behavior with exit status). + if (exitDetails === undefined) { result += "" - exitStatus = `Exit code: ` + } else if (!exitDetails.signalName && exitDetails.exitCode === undefined) { + result += "" } + const exitStatus = formatExitStatus(exitDetails) + return [ false, `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index d445f0aeb5..bf186046a5 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -7,6 +7,7 @@ import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { unescapeHtmlEntities } from "../../../utils/text-normalization" +import { Terminal } from "../../../integrations/terminal/Terminal" // Mock dependencies vitest.mock("execa", () => ({ @@ -256,6 +257,27 @@ describe("executeCommandTool", () => { expect(mockAskApproval).not.toHaveBeenCalled() // executeCommandInTerminal should not be called since rooignore blocked it }) + + it("allows Execa retry when shell integration fails before command submission", () => { + const error = new executeCommandModule.ShellIntegrationError("startup failed", false) + + expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(true) + }) + + it("prevents Execa retry when shell integration fails after command submission", () => { + const error = new executeCommandModule.ShellIntegrationError("stream missing", true) + + expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false) + }) + + it("selects the Execa fallback provider for cmd.exe shell integration", () => { + vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true) + + expect(executeCommandModule.getTerminalProviderForExecution(false)).toEqual({ + terminalProvider: "execa", + isCmdExeFallback: true, + }) + }) }) describe("Command execution timeout configuration", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b213c0862d..38ad631271 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -781,6 +781,7 @@ export class ClineProvider terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, + terminalProfile, ttsEnabled, ttsSpeed, }) => { @@ -792,6 +793,7 @@ export class ClineProvider Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) + Terminal.setTerminalProfile(terminalProfile) setTtsEnabled(ttsEnabled ?? false) setTtsSpeed(ttsSpeed ?? 1) }, @@ -2084,6 +2086,7 @@ export class ClineProvider terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, mcpEnabled, currentApiConfigName, listApiConfigMeta, @@ -2237,6 +2240,7 @@ export class ClineProvider terminalZshOhMy: terminalZshOhMy ?? false, terminalZshP10k: terminalZshP10k ?? false, terminalZdotdir: terminalZdotdir ?? false, + terminalProfile, mcpEnabled: mcpEnabled ?? true, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], @@ -2441,6 +2445,7 @@ export class ClineProvider terminalZshOhMy: stateValues.terminalZshOhMy ?? false, terminalZshP10k: stateValues.terminalZshP10k ?? false, terminalZdotdir: stateValues.terminalZdotdir ?? false, + terminalProfile: stateValues.terminalProfile, mode: stateValues.mode ?? defaultModeSlug, language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 16ab791c26..fd63f2a978 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -24,6 +24,7 @@ import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" +import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" // Mock setup must come before imports. @@ -471,6 +472,20 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + 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. + await (provider as any).contextProxy.setValue("terminalProfile", "Git Bash") + + await provider.resolveWebviewView(mockWebviewView) + // The hydration runs in a getState().then(...) callback, so flush microtasks. + await new Promise((resolve) => setImmediate(resolve)) + + expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash") + + setTerminalProfileSpy.mockRestore() + }) + test("resolveWebviewView sets up webview correctly", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4af2be1e38..224e9dece6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -103,6 +103,10 @@ vi.mock("vscode", () => { workspace: { workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], openTextDocument, + getConfiguration: vi.fn(() => ({ get: vi.fn() })), + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), }, } }) @@ -167,6 +171,8 @@ vi.mock("../../mentions/resolveImageMentions", () => ({ })) import { resolveImageMentions } from "../../mentions/resolveImageMentions" +import { Terminal } from "../../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { @@ -866,6 +872,129 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - terminalProfile", () => { + beforeEach(() => { + vi.clearAllMocks() + Terminal.setTerminalProfile(undefined) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + it("normalizes and persists a saved terminalProfile, then closes stale idle terminals", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: " Git Bash " }, + }) + + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash") + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) + + it("does not close idle terminals when hydration sends the unchanged profile", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: " Git Bash " }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash") + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + }) + + it("clears the persisted profile when SettingsView sends the empty-string sentinel", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: "" }, + }) + + expect(Terminal.getTerminalProfile()).toBeUndefined() + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) + + it("does not close idle terminals when the empty-string sentinel leaves the profile unset", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: "" }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + }) + + it("treats non-string terminalProfile values as unset", async () => { + Terminal.setTerminalProfile("Git Bash") + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { terminalProfile: 42 as any }, + }) + + expect(Terminal.getTerminalProfile()).toBeUndefined() + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined) + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("webviewMessageHandler - requestTerminalProfiles", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("posts available profile names", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockReturnValue(["Git Bash", "bash"]) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: ["Git Bash", "bash"], + }) + }) + + it("posts an empty array when profile discovery throws", async () => { + vi.spyOn(Terminal, "getAvailableProfileNames").mockImplementation(() => { + throw new Error("config error") + }) + + await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "terminalProfiles", + profiles: [], + }) + }) +}) + +describe("webviewMessageHandler - openTerminalProfilePicker", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("executes the VS Code selectDefaultShell command", async () => { + await webviewMessageHandler(mockClineProvider, { type: "openTerminalProfilePicker" }) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.selectDefaultShell") + }) +}) + describe("webviewMessageHandler - requestCommands", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b5bda4b147..2ee7373119 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -50,6 +50,7 @@ import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" import { selectImages } from "../../integrations/misc/process-images" @@ -726,6 +727,17 @@ export const webviewMessageHandler = async ( if (value !== undefined) { Terminal.setTerminalZdotdir(value as boolean) } + } else if (key === "terminalProfile") { + const previousProfile = Terminal.getTerminalProfile() + Terminal.setTerminalProfile(typeof value === "string" ? value : undefined) + newValue = Terminal.getTerminalProfile() + + if (newValue !== previousProfile) { + // Discard idle terminals so the next command gets a fresh + // terminal using the new profile's shell instead of reusing + // a stale one from the previous profile. + TerminalRegistry.closeIdleTerminals() + } } else if (key === "execaShellPath") { Terminal.setExecaShellPath(value as string | undefined) } else if (key === "mcpEnabled") { @@ -1316,6 +1328,12 @@ export const webviewMessageHandler = async ( break } + case "openTerminalProfilePicker": { + // Open VS Code's native terminal profile picker so the user can set the + // default shell without leaving VS Code's own settings UI. + await vscode.commands.executeCommand("workbench.action.terminal.selectDefaultShell") + break + } case "openKeyboardShortcuts": { // Open VSCode keyboard shortcuts settings and optionally filter to show the Roo Code commands const searchQuery = message.text || "" @@ -1517,6 +1535,27 @@ export const webviewMessageHandler = async ( break + case "requestTerminalProfiles": { + // Allowlisted request: read VS Code's terminal profiles server-side and + // return only the sanitized profile names. The terminal profile dropdown + // only needs names, so this avoids routing it through the generic + // `getVSCodeSetting` handler (which reads any key the webview supplies). + // Only profiles with a resolvable `path` are returned — source-only + // profiles (e.g. { source: "PowerShell" }) cannot be mapped to a shell + // binary by an extension and would silently fall back to the default. + try { + await provider.postMessageToWebview({ + type: "terminalProfiles", + profiles: Terminal.getAvailableProfileNames(), + }) + } catch (error) { + console.error("Failed to get terminal profiles:", error) + await provider.postMessageToWebview({ type: "terminalProfiles", profiles: [] }) + } + + break + } + case "mode": await provider.handleModeSwitch(message.text as Mode) break diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..e326509c3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" 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 { McpServerManager } from "./services/mcp/McpServerManager" @@ -387,5 +388,6 @@ export async function deactivate() { await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() + Terminal.setTerminalProfile(undefined) TerminalRegistry.cleanup() } diff --git a/src/extension/__tests__/api-terminal-profile.spec.ts b/src/extension/__tests__/api-terminal-profile.spec.ts new file mode 100644 index 0000000000..9c7f80654f --- /dev/null +++ b/src/extension/__tests__/api-terminal-profile.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import type * as vscode from "vscode" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +describe("API - terminal profile", () => { + let api: API + + beforeEach(() => { + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const provider = { + context: {}, + on: vi.fn(), + } as unknown as ClineProvider + + Terminal.setTerminalProfile(undefined) + api = new API(outputChannel, provider) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + it("closes idle terminals only when the normalized profile changes", () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + + api.setTerminalProfile(" Git Bash ") + api.setTerminalProfile("Git Bash") + + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index af21028944..0f056f16da 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -24,6 +24,8 @@ import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" +import { Terminal } from "../integrations/terminal/Terminal" +import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { openClineInNewTab } from "../activate/registerCommands" import { getCommands } from "../services/command/commands" import { getModels } from "../api/providers/fetchers/modelCache" @@ -477,6 +479,15 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + public setTerminalProfile(name: string | undefined): void { + const previousProfile = Terminal.getTerminalProfile() + Terminal.setTerminalProfile(name) + + if (Terminal.getTerminalProfile() !== previousProfile) { + TerminalRegistry.closeIdleTerminals() + } + } + // Provider Profile Management public getProfiles(): string[] { diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index ee26254934..7480f271d3 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -13,6 +13,7 @@ export abstract class BaseTerminal implements RooTerminal { public readonly provider: RooTerminalProvider public readonly id: number public readonly initialCwd: string + public readonly reuseKey: string public busy: boolean public running: boolean @@ -22,10 +23,11 @@ export abstract class BaseTerminal implements RooTerminal { public process?: RooTerminalProcess public completedProcesses: RooTerminalProcess[] = [] - constructor(provider: RooTerminalProvider, id: number, cwd: string) { + constructor(provider: RooTerminalProvider, id: number, cwd: string, reuseKey: string = provider) { this.provider = provider this.id = id this.initialCwd = cwd + this.reuseKey = reuseKey this.busy = false this.running = false this.streamClosed = false @@ -42,7 +44,7 @@ export abstract class BaseTerminal implements RooTerminal { /** * Sets the active stream for this terminal and notifies the process * @param stream The stream to set, or undefined to clean up - * @throws Error if process is undefined when a stream is provided + * If no process exists when a stream is provided, logs a warning and returns. */ public setActiveStream(stream: AsyncIterable | undefined, pid?: number): void { if (stream) { @@ -161,6 +163,7 @@ export abstract class BaseTerminal implements RooTerminal { private static terminalZshOhMy: boolean = false private static terminalZshP10k: boolean = false private static terminalZdotdir: boolean = false + private static terminalProfile: string | undefined = undefined private static execaShellPath: string | undefined = undefined /** @@ -296,6 +299,25 @@ export abstract class BaseTerminal implements RooTerminal { return BaseTerminal.terminalZdotdir } + /** + * Sets the name of the VS Code terminal profile to use for the integrated + * terminal. An empty/undefined value falls back to VS Code's default terminal + * behavior. + * @param profile The terminal profile name, or undefined for the default + */ + public static setTerminalProfile(profile: string | undefined): void { + const normalized = profile?.trim() + BaseTerminal.terminalProfile = normalized && normalized.length > 0 ? normalized : undefined + } + + /** + * Gets the name of the VS Code terminal profile to use for the integrated terminal. + * @returns The terminal profile name, or undefined when the default should be used + */ + public static getTerminalProfile(): string | undefined { + return BaseTerminal.terminalProfile + } + public static setExecaShellPath(shellPath: string | undefined): void { BaseTerminal.execaShellPath = shellPath } diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 38ace9d4b1..49dc347c52 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -1,3 +1,6 @@ +import { existsSync } from "fs" +import * as path from "path" + import * as vscode from "vscode" import pWaitFor from "p-wait-for" @@ -12,14 +15,49 @@ export class Terminal extends BaseTerminal { public cmdCounter: number = 0 + public activeShellExecution?: vscode.TerminalShellExecution + constructor(id: number, terminal: vscode.Terminal | undefined, cwd: string) { - super("vscode", id, cwd) + super("vscode", id, cwd, Terminal.getReuseKey()) const env = Terminal.getEnv() const iconPath = new vscode.ThemeIcon("rocket") - this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env }) - if (Terminal.getTerminalZdotdir()) { + if (terminal) { + this.terminal = terminal + } else { + const options: vscode.TerminalOptions = { cwd, name: "Zoo Code", iconPath, env } + + // When the user has chosen a VS Code terminal profile, resolve it to a + // shell path/args/env so the integrated terminal uses that shell. When + // unset, shellPath/shellArgs are left undefined so VS Code's default + // terminal behavior is preserved. + const profileShell = Terminal.getProfileShell() + + if (profileShell?.shellPath) { + options.shellPath = profileShell.shellPath + + if (profileShell.shellArgs) { + options.shellArgs = profileShell.shellArgs + } + + console.info( + `[Terminal] Creating terminal with profile "${Terminal.getTerminalProfile()}" -> ${profileShell.shellPath}`, + ) + + // Preserve profile-specific variables (e.g. locale/PATH), but keep + // Zoo Code's shell-integration controls authoritative. + if (profileShell.env) { + options.env = { ...profileShell.env, ...env } + } + } + + this.terminal = vscode.window.createTerminal(options) + } + + // Only register ZDOTDIR cleanup when we actually set it (i.e. no profile + // override is active — see getEnv() for the same guard). + if (Terminal.getTerminalZdotdir() && !Terminal.getTerminalProfile()) { ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR) } } @@ -57,7 +95,7 @@ export class Terminal extends BaseTerminal { process.once("completed", (output) => callbacks.onCompleted(output, process)) process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process)) process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process)) - process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process)) + process.once("no_shell_integration", (details) => callbacks.onNoShellIntegration?.(details, process)) const promise = new Promise((resolve, reject) => { // Set up event handlers @@ -67,28 +105,41 @@ export class Terminal extends BaseTerminal { reject(error) }) - // Wait for shell integration before executing the command - pWaitFor(() => this.terminal.shellIntegration !== undefined, { - timeout: Terminal.getShellIntegrationTimeout(), - }) - .then(() => { - // Clean up temporary directory if shell integration is available, zsh did its job: - ShellIntegrationManager.zshCleanupTmpDir(this.id) - - // Run the command in the terminal - process.run(command) + if (Terminal.isActiveShellCmdExe()) { + // Keep this defensive fallback for callers that invoke Terminal.runCommand() + // directly instead of routing through executeCommandInTerminal(). + // cmd.exe cannot emit OSC 633;A — skip the timeout entirely and go + // straight to the execa fallback (VS Code issue #164646). + ShellIntegrationManager.zshCleanupTmpDir(this.id) + process.emit("no_shell_integration", { + message: + "cmd.exe does not support shell integration (VS Code issue #164646). Command will run via fallback.", + commandSubmitted: false, }) - .catch(() => { - console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) - - // Clean up temporary directory if shell integration is not available - ShellIntegrationManager.zshCleanupTmpDir(this.id) - - process.emit( - "no_shell_integration", - `Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.getShellIntegrationTimeout() / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`, - ) + } else { + // Wait for shell integration before executing the command + pWaitFor(() => this.terminal.shellIntegration !== undefined, { + timeout: Terminal.getShellIntegrationTimeout(), }) + .then(() => { + // Clean up temporary directory if shell integration is available, zsh did its job: + ShellIntegrationManager.zshCleanupTmpDir(this.id) + + // Run the command in the terminal + process.run(command) + }) + .catch(() => { + console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) + + // Clean up temporary directory if shell integration is not available + ShellIntegrationManager.zshCleanupTmpDir(this.id) + + process.emit("no_shell_integration", { + message: `Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.getShellIntegrationTimeout() / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`, + commandSubmitted: false, + }) + }) + } }) return mergePromise(process, promise) @@ -184,11 +235,355 @@ export class Terminal extends BaseTerminal { env.PROMPT_EOL_MARK = "" } - // Handle ZDOTDIR for zsh if enabled - if (Terminal.getTerminalZdotdir()) { + // Handle ZDOTDIR for zsh if enabled. Skip when a profile override is + // active: VS Code's own shell integration injector also sets ZDOTDIR for + // zsh, and the two would fight each other (VS Code's ambient env wins per + // issue #96295). Let VS Code handle injection for the selected profile. + if (Terminal.getTerminalZdotdir() && !Terminal.getTerminalProfile()) { env.ZDOTDIR = ShellIntegrationManager.zshInitTmpDir(env) } return env } + + /** + * Returns the VS Code config section key (`windows`/`osx`/`linux`) used for + * platform-specific terminal profiles. + */ + public static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" { + if (platform === "win32") { + return "windows" + } + + if (platform === "darwin") { + return "osx" + } + + return "linux" + } + + /** + * Resolves a profile path to an executable on disk. VS Code's built-in Unix + * profiles commonly use bare command names such as `bash`, so check PATH in + * addition to explicit filesystem paths. + */ + public static resolveProfilePath( + profilePath: unknown, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + ): string | undefined { + const candidates = Array.isArray(profilePath) ? profilePath : [profilePath] + const pathValue = env.PATH ?? env.Path ?? env.path + const pathEntries = pathValue?.split(platform === "win32" ? ";" : ":") ?? [] + const platformJoin = platform === "win32" ? path.win32.join : path.posix.join + + for (const value of candidates) { + if (typeof value !== "string") { + continue + } + + const candidate = value.trim() + + if (!candidate) { + continue + } + + if (/[\\/]/.test(candidate)) { + if (existsSync(candidate)) { + return candidate + } + + continue + } + + const extensions = + platform === "win32" && path.extname(candidate) === "" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") + : [""] + + for (const entry of pathEntries) { + const directory = entry.replace(/^"(.*)"$/, "$1") + + for (const extension of extensions) { + const resolved = platformJoin(directory, `${candidate}${extension}`) + + if (existsSync(resolved)) { + return resolved + } + } + } + } + + return undefined + } + + /** + * Reads profiles from trusted settings scopes only. Workspace settings are + * intentionally excluded because opening a repository must not allow its + * `.vscode/settings.json` to select an executable for Zoo Code to launch. + */ + public static getConfiguredProfiles(platform: NodeJS.Platform = process.platform): Record { + const platformKey = Terminal.getPlatformProfileKey(platform) + const configuration = vscode.workspace.getConfiguration("terminal.integrated.profiles") + + // Some test doubles and older embedders expose get() without inspect(). + // Falling back to no profiles preserves the trusted-scope guarantee. + if (typeof configuration.inspect !== "function") { + return {} + } + + const inspected = configuration.inspect>(platformKey) + + return { + ...(inspected?.defaultValue ?? {}), + ...(inspected?.globalValue ?? {}), + } + } + + /** + * Reads the configured default profile from trusted settings scopes only. + */ + public static getConfiguredDefaultProfileName(platform: NodeJS.Platform = process.platform): string | undefined { + const platformKey = Terminal.getPlatformProfileKey(platform) + const configuration = vscode.workspace.getConfiguration("terminal.integrated") + + // Some test doubles and older embedders expose get() without inspect(). + // Falling back to undefined preserves the trusted-scope guarantee. + if (typeof configuration.inspect !== "function") { + return undefined + } + + const inspected = configuration.inspect(`defaultProfile.${platformKey}`) + + return inspected?.globalValue ?? inspected?.defaultValue + } + + /** + * Returns true when the resolved shell path is cmd.exe. cmd.exe cannot emit + * the OSC 633;C sequence (VS Code issue #164646, closed as not planned), so + * shell integration will never work for it — exclude it from the picker. + */ + public static isCmdExe(shellPath: string): boolean { + return /[/\\]cmd\.exe$/i.test(shellPath) + } + + public static isPowerShell(shellPath: string): boolean { + return /[/\\](?:pwsh|powershell)(?:\.exe)?$/i.test(shellPath) + } + + public static isFish(shellPath: string): boolean { + return /[/\\]fish(?:\.exe)?$/i.test(shellPath) + } + + /** + * Returns true when the active shell (profile override or VS Code default) is + * cmd.exe. Used to skip the shell integration timeout entirely for cmd.exe. + */ + public static isActiveShellCmdExe(platform: NodeJS.Platform = process.platform): boolean { + if (platform !== "win32") { + return false + } + + // Check explicit profile override first. + const profileShell = Terminal.getProfileShell(platform) + + if (profileShell?.shellPath) { + return Terminal.isCmdExe(profileShell.shellPath) + } + + // Fall back to VS Code's configured default profile for Windows. + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + return resolved ? Terminal.isCmdExe(resolved) : false + } + + public static isActiveShellPowerShell(platform: NodeJS.Platform = process.platform): boolean { + if (platform !== "win32") { + return false + } + + const profileOverride = Terminal.getTerminalProfile() + + if (profileOverride) { + const profileShell = Terminal.getProfileShell(platform) + return profileShell?.shellPath ? Terminal.isPowerShell(profileShell.shellPath) : false + } + + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown; source?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + + if (resolved) { + return Terminal.isPowerShell(resolved) + } + + return typeof profile.source === "string" && profile.source.toLowerCase().includes("powershell") + } + + public static isActiveShellFish(platform: NodeJS.Platform = process.platform): boolean { + const profileOverride = Terminal.getTerminalProfile() + + if (profileOverride) { + const profileShell = Terminal.getProfileShell(platform) + return profileShell?.shellPath ? Terminal.isFish(profileShell.shellPath) : false + } + + const defaultProfileName = Terminal.getConfiguredDefaultProfileName(platform) + + if (!defaultProfileName) { + return false + } + + const profiles = Terminal.getConfiguredProfiles(platform) + const profile = profiles[defaultProfileName] as { path?: unknown } | null | undefined + + if (!profile) { + return false + } + + const resolved = Terminal.resolveProfilePath(profile.path, platform) + return resolved ? Terminal.isFish(resolved) : false + } + + public static getAvailableProfileNames(platform: NodeJS.Platform = process.platform): string[] { + const names: string[] = [] + + for (const [name, entry] of Object.entries(Terminal.getConfiguredProfiles(platform))) { + if (!entry || typeof entry !== "object") { + continue + } + + const { path: profilePath } = entry as { path?: unknown } + const resolved = Terminal.resolveProfilePath(profilePath, platform) + + if (resolved && !Terminal.isCmdExe(resolved)) { + names.push(name) + } + } + + return names.sort() + } + + /** + * Returns a stable key that prevents terminals created with different VS Code + * profile overrides from being reused interchangeably. + */ + public static getReuseKey(): string { + return `vscode:${Terminal.getTerminalProfile() ?? "default"}` + } + + /** + * Resolves the configured VS Code terminal profile (see `terminalProfile` + * setting / {@link Terminal.getTerminalProfile}) into a shell path and args by + * reading VS Code's `terminal.integrated.profiles.` configuration. + * + * This reuses VS Code's terminal profile concept so users can pick, for + * example, a Git Bash profile instead of the default shell. Only profiles + * with a resolvable `path` are supported; source-only profiles (e.g. + * `{ source: "PowerShell" }`) cannot be mapped to a shell binary by an + * extension and return undefined. + * + * @returns The resolved shell path/args, or undefined when no profile is + * configured or the profile cannot be resolved (default behavior). + */ + public static getProfileShell( + platform: NodeJS.Platform = process.platform, + ): { shellPath: string; shellArgs?: string[]; env?: Record } | undefined { + const profileName = Terminal.getTerminalProfile() + + if (!profileName) { + return undefined + } + + const platformKey = Terminal.getPlatformProfileKey(platform) + + const profiles = Terminal.getConfiguredProfiles(platform) + + const profile = profiles?.[profileName] as + | { + path?: string | string[] + args?: string | string[] + source?: string + env?: Record + } + | null + | undefined + + if (!profile) { + console.warn(`[Terminal] Configured terminal profile "${profileName}" not found for ${platformKey}.`) + return undefined + } + + const pathValue = Terminal.resolveProfilePath(profile.path, platform) + + if (!pathValue) { + // Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to + // a shell path here, so we fall back to the default terminal. + console.warn( + `[Terminal] Terminal profile "${profileName}" has no resolvable "path"; using default terminal.`, + ) + return undefined + } + + const shellArgs = Array.isArray(profile.args) + ? profile.args.filter((arg): arg is string => typeof arg === "string") + : typeof profile.args === "string" + ? [profile.args] + : undefined + + // VS Code profiles may declare their own `env` (e.g. to set a UTF-8 locale or + // a custom PATH). Preserve it so the inline terminal doesn't lose environment + // the user configured on the profile. A `null` value unsets that variable. + // Values come from user `settings.json`, so sanitize to string/null only. + let env: Record | undefined + + if (profile.env && typeof profile.env === "object") { + const sanitized: Record = {} + const blockedKeys = new Set([ + "ZDOTDIR", + "PROMPT_COMMAND", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "BASH_ENV", + "ENV", + ]) + + for (const [key, val] of Object.entries(profile.env)) { + if (!blockedKeys.has(key.toUpperCase()) && (typeof val === "string" || val === null)) { + sanitized[key] = val + } + } + + if (Object.keys(sanitized).length > 0) { + env = sanitized + } + } + + return { shellPath: pathValue, shellArgs, env } + } } diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 465611c2f9..694c5ff340 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode" -import { inspect } from "util" import type { ExitCodeDetails } from "./types" import { BaseTerminalProcess } from "./BaseTerminalProcess" @@ -62,10 +61,10 @@ export class TerminalProcess extends BaseTerminalProcess { "[TerminalProcess] Shell integration not available. Command sent without knowledge of response.", ) - this.emit( - "no_shell_integration", - "Command was submitted; output is not available, as shell integration is inactive.", - ) + this.emit("no_shell_integration", { + message: "Command was submitted; output is not available, as shell integration is inactive.", + commandSubmitted: true, + }) this.emit( "completed", @@ -83,10 +82,10 @@ export class TerminalProcess extends BaseTerminalProcess { this.removeAllListeners("stream_available") // Emit no_shell_integration event with descriptive message - this.emit( - "no_shell_integration", - `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`, - ) + this.emit("no_shell_integration", { + message: `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`, + commandSubmitted: true, + }) // Reject with descriptive error reject( @@ -108,19 +107,19 @@ export class TerminalProcess extends BaseTerminalProcess { this.once("shell_execution_complete", (details: ExitCodeDetails) => resolve(details)) }) - // Execute command - const defaultWindowsShellProfile = vscode.workspace - .getConfiguration("terminal.integrated.defaultProfile") - .get("windows") - - const isPowerShell = - process.platform === "win32" && - (defaultWindowsShellProfile === null || - (defaultWindowsShellProfile as string)?.toLowerCase().includes("powershell")) - - if (isPowerShell) { - let commandToExecute = command + // Execute command. + // Determine whether the active shell is PowerShell so we can apply the + // PS-specific counter/sleep workarounds. Prefer the Zoo Code profile + // override (if set) over the VS Code default profile. Fix for the wrong + // config API: must be getConfiguration("terminal.integrated").get( + // "defaultProfile.windows"), not the reversed form that always returns null. + const shellKind = { + isPowerShell: Terminal.isActiveShellPowerShell(), + isFish: Terminal.isActiveShellFish(), + } + let commandToExecute = command + if (shellKind.isPowerShell) { // Only add the PowerShell counter workaround if enabled if (Terminal.getPowershellCounter()) { commandToExecute += ` ; "(Roo/PS Workaround: ${this.terminal.cmdCounter++})" > $null` @@ -130,10 +129,22 @@ export class TerminalProcess extends BaseTerminalProcess { if (Terminal.getCommandDelay() > 0) { commandToExecute += ` ; start-sleep -milliseconds ${Terminal.getCommandDelay()}` } + } - terminal.shellIntegration.executeCommand(commandToExecute) - } else { - terminal.shellIntegration.executeCommand(command) + try { + const execution = terminal.shellIntegration.executeCommand( + this.prepareCommandForShellIntegration(commandToExecute, shellKind), + ) + + this.terminal.activeShellExecution = execution + + // VS Code only captures data written after read() is first called, so read + // the execution stream immediately instead of waiting for the global start + // event to deliver the same execution later. + this.terminal.setActiveStream(execution.read()) + } catch (error) { + this.terminal.activeShellExecution = undefined + throw error } this.isHot = true @@ -160,9 +171,6 @@ export class TerminalProcess extends BaseTerminalProcess { return } - let preOutput = "" - let commandOutputStarted = false - /* * Extract clean output from raw accumulated output. FYI: * ]633 is a custom sequence number used by VSCode shell integration: @@ -175,22 +183,14 @@ export class TerminalProcess extends BaseTerminalProcess { // Process stream data for await (let data of stream) { - // Check for command output start marker - if (!commandOutputStarted) { - preOutput += data - const match = this.matchAfterVsceStartMarkers(data) - - if (match !== undefined) { - commandOutputStarted = true - data = match - this.fullOutput = "" // Reset fullOutput when command actually starts - this.emit("line", "") // Trigger UI to proceed - } else { - continue - } + const match = this.fullOutput === "" ? this.matchAfterVsceStartMarkers(data) : undefined + + if (match !== undefined) { + data = match + this.emit("line", "") // Trigger UI to proceed } - // Command output started, accumulate data without filtering. + // Accumulate data without filtering. // notice to future programmers: do not add escape sequence // filtering here: fullOutput cannot change in length (see getUnretrievedOutput), // and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream. @@ -214,35 +214,12 @@ export class TerminalProcess extends BaseTerminalProcess { // Wait for shell execution to complete. await shellExecutionComplete + this.terminal.activeShellExecution = undefined this.isHot = false - if (commandOutputStarted) { - // Emit any remaining output before completing - this.emitRemainingBufferIfListening() - } else { - const errorMsg = - "VSCE output start escape sequence (]633;C or ]133;C) not received, but the stream has started. Upstream VSCE Bug?" - - const inspectPreOutput = inspect(preOutput, { colors: false, breakLength: Infinity }) - console.error(`[Terminal Process] ${errorMsg} preOutput: ${inspectPreOutput}`) - - // Emit no_shell_integration event - this.emit("no_shell_integration", errorMsg) - - // Emit completed event with error message - this.emit( - "completed", - "\n" + - `${inspectPreOutput}\n` + - "AI MODEL: You MUST notify the user with the information above so they can open a bug report.", - ) - - this.continue() - - // Return early since we can't process output without shell integration markers - return - } + // Emit any remaining output before completing. + this.emitRemainingBufferIfListening() // fullOutput begins after C marker so we only need to trim off D marker // (if D exists, see VSCode bug# 237208): @@ -261,6 +238,31 @@ export class TerminalProcess extends BaseTerminalProcess { this.emit("continue") } + /** + * VS Code reports each complete top-level statement in multiline input as a + * separate shell execution. Keep the submitted script in one execution so a + * leading assignment cannot complete and detach the tracked process before + * the remaining statements run. + */ + private prepareCommandForShellIntegration( + command: string, + shellKind: { isPowerShell: boolean; isFish: boolean }, + ): string { + if (!command.includes("\n")) { + return command + } + + if (shellKind.isPowerShell) { + return `. {\n${command}\n}` + } + + if (shellKind.isFish) { + return `begin\n${command}\nend` + } + + return `{\n${command}\n}` + } + public override continue() { this.emitRemainingBufferIfListening() this.isListening = false diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index 358793fc21..c21eddb03f 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -48,8 +48,6 @@ export class TerminalRegistry { try { const startDisposable = vscode.window.onDidStartTerminalShellExecution?.( async (e: vscode.TerminalShellExecutionStartEvent) => { - // Get a handle to the stream as early as possible: - const stream = e.execution.read() const terminal = this.getTerminalByVSCETerminal(e.terminal) console.info("[onDidStartTerminalShellExecution]", { @@ -57,7 +55,13 @@ export class TerminalRegistry { terminalId: terminal?.id, }) - if (terminal) { + if (terminal instanceof Terminal) { + if (terminal.activeShellExecution === e.execution) { + return + } + + // Get a handle to the stream as early as possible. + const stream = e.execution.read() terminal.setActiveStream(stream) terminal.busy = true // Mark terminal as busy when shell execution starts } else { @@ -94,6 +98,10 @@ export class TerminalRegistry { return } + if (terminal instanceof Terminal && terminal.activeShellExecution === e.execution) { + terminal.activeShellExecution = undefined + } + if (!terminal.running) { console.error( "[TerminalRegistry] Shell execution end event received, but process is not running for terminal:", @@ -155,13 +163,14 @@ export class TerminalRegistry { provider: RooTerminalProvider = "vscode", ): Promise { const terminals = this.getAllTerminals() + const reuseKey = provider === "vscode" ? Terminal.getReuseKey() : provider let terminal: RooTerminal | undefined // First priority: Find a terminal already assigned to this task with // matching directory. if (taskId) { terminal = terminals.find((t) => { - if (t.busy || t.taskId !== taskId || t.provider !== provider) { + if (t.busy || t.taskId !== taskId || t.provider !== provider || t.reuseKey !== reuseKey) { return false } @@ -178,7 +187,7 @@ export class TerminalRegistry { // Second priority: Find any available terminal with matching directory. if (!terminal) { terminal = terminals.find((t) => { - if (t.busy || t.provider !== provider) { + if (t.busy || t.provider !== provider || t.reuseKey !== reuseKey) { return false } @@ -276,6 +285,22 @@ export class TerminalRegistry { this.disposables = [] } + /** + * Disposes all idle (non-busy) VS Code terminals so they are not reused + * after a shell profile change. Busy terminals are left untouched. + */ + public static closeIdleTerminals(): void { + this.terminals = this.terminals.filter((t) => { + if (t.busy || !(t instanceof Terminal)) { + return true + } + + t.terminal.dispose() + ShellIntegrationManager.zshCleanupTmpDir(t.id) + return false + }) + } + /** * Releases all terminals associated with a task. * diff --git a/src/integrations/terminal/__tests__/TerminalProcess.spec.ts b/src/integrations/terminal/__tests__/TerminalProcess.spec.ts index 890d76d6e6..a1755f60dc 100644 --- a/src/integrations/terminal/__tests__/TerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcess.spec.ts @@ -50,6 +50,7 @@ describe("TerminalProcess", () => { // Create a process for testing terminalProcess = new TestTerminalProcess(mockTerminalInfo) + mockTerminalInfo.process = terminalProcess TerminalRegistry["terminals"].push(mockTerminalInfo) @@ -58,6 +59,35 @@ describe("TerminalProcess", () => { }) describe("run", () => { + it("emits no_shell_integration with commandSubmitted=false when shell integration startup times out", async () => { + vi.useFakeTimers() + const previousTimeout = Terminal.getShellIntegrationTimeout() + Terminal.setShellIntegrationTimeout(10) + + try { + mockTerminal.shellIntegration = undefined + let commandSubmitted: boolean | undefined + const runPromise = mockTerminalInfo.runCommand("test command", { + onLine: vi.fn(), + onCompleted: vi.fn(), + onShellExecutionStarted: vi.fn(), + onShellExecutionComplete: vi.fn(), + onNoShellIntegration: (details) => { + commandSubmitted = details.commandSubmitted + }, + }) + + await vi.advanceTimersByTimeAsync(20) + await runPromise + + expect(commandSubmitted).toBe(false) + expect(mockTerminal.sendText).not.toHaveBeenCalled() + } finally { + Terminal.setShellIntegrationTimeout(previousTimeout) + vi.useRealTimers() + } + }) + it("handles shell integration commands correctly", async () => { let lines: string[] = [] @@ -91,6 +121,57 @@ describe("TerminalProcess", () => { expect(terminalProcess.isHot).toBe(false) }) + it("wraps multiline POSIX scripts so VS Code tracks them as one shell execution", async () => { + const command = 'PR_SHA=abc123\nfor f in one two; do\n echo "$f @ $PR_SHA"\ndone' + + mockStream = (async function* () { + yield "\x1b]633;C\x07" + yield "one @ abc123\ntwo @ abc123\n" + yield "\x1b]633;D\x07" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockTerminal.shellIntegration.executeCommand.mockReturnValue({ + read: vi.fn().mockReturnValue(mockStream), + }) + + const runPromise = terminalProcess.run(command) + terminalProcess.emit("stream_available", mockStream) + await runPromise + + expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(`{\n${command}\n}`) + }) + + it.each([ + ["PowerShell", true, false, ". {\necho one\necho two\n}"], + ["fish", false, true, "begin\necho one\necho two\nend"], + ])("uses the %s multiline wrapper", async (_profile, isPowerShell, isFish, expectedCommand) => { + const psSpy = vi.spyOn(Terminal, "isActiveShellPowerShell").mockReturnValue(isPowerShell) + const fishSpy = vi.spyOn(Terminal, "isActiveShellFish").mockReturnValue(isFish) + + try { + mockStream = (async function* () { + yield "\x1b]633;C\x07" + yield "one\ntwo\n" + yield "\x1b]633;D\x07" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockTerminal.shellIntegration.executeCommand.mockReturnValue({ + read: vi.fn().mockReturnValue(mockStream), + }) + + const runPromise = terminalProcess.run("echo one\necho two") + terminalProcess.emit("stream_available", mockStream) + await runPromise + + expect(mockTerminal.shellIntegration.executeCommand).toHaveBeenCalledWith(expectedCommand) + } finally { + psSpy.mockRestore() + fishSpy.mockRestore() + } + }) + it("handles terminals without shell integration", async () => { // Temporarily suppress the expected console.warn for this test const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) @@ -114,11 +195,15 @@ describe("TerminalProcess", () => { // Create new process with the no-shell terminal const noShellProcess = new TerminalProcess(noShellTerminalInfo) + let commandSubmitted: boolean | undefined // Set up event listeners to verify events are emitted const eventPromises = Promise.all([ new Promise((resolve) => - noShellProcess.once("no_shell_integration", (_message: string) => resolve()), + noShellProcess.once("no_shell_integration", (details) => { + commandSubmitted = details.commandSubmitted + resolve() + }), ), new Promise((resolve) => noShellProcess.once("completed", (_output?: string) => resolve())), new Promise((resolve) => noShellProcess.once("continue", resolve)), @@ -130,11 +215,80 @@ describe("TerminalProcess", () => { // Verify sendText was called with the command expect(noShellTerminal.sendText).toHaveBeenCalledWith("test command", true) + expect(commandSubmitted).toBe(true) // Restore the original console.warn consoleWarnSpy.mockRestore() }) + it("completes without warning when the execution stream is empty after submission", async () => { + const noShellIntegrationSpy = vi.fn() + let completedOutput: string | undefined + + const eventPromises = Promise.all([ + new Promise((resolve) => + terminalProcess.once("completed", (output?: string) => { + completedOutput = output + resolve() + }), + ), + new Promise((resolve) => terminalProcess.once("continue", resolve)), + ]) + + async function* emptyStream(): AsyncGenerator { + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + return + yield "" // satisfy require-yield; never reached + } + mockStream = emptyStream() + + mockExecution = { read: vi.fn().mockReturnValue(mockStream) } + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + terminalProcess.once("no_shell_integration", noShellIntegrationSpy) + + const runPromise = terminalProcess.run("test command") + await runPromise + await eventPromises + + expect(mockExecution.read).toHaveBeenCalledTimes(1) + expect(completedOutput).toBe("") + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + + it("captures execution output even when VS Code does not include start markers", async () => { + const noShellIntegrationSpy = vi.fn() + let completedOutput: string | undefined + + const eventPromises = Promise.all([ + new Promise((resolve) => + terminalProcess.once("completed", (output?: string) => { + completedOutput = output + resolve() + }), + ), + new Promise((resolve) => terminalProcess.once("continue", resolve)), + ]) + + mockStream = (async function* () { + yield "some output without marker\n" + terminalProcess.emit("shell_execution_complete", { exitCode: 0 }) + })() + + mockExecution = { read: vi.fn().mockReturnValue(mockStream) } + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + terminalProcess.once("no_shell_integration", noShellIntegrationSpy) + + const runPromise = terminalProcess.run("test command") + await runPromise + await eventPromises + + expect(mockExecution.read).toHaveBeenCalledTimes(1) + expect(completedOutput).toBe("some output without marker\n") + expect(noShellIntegrationSpy).not.toHaveBeenCalled() + }) + it("sets hot state for compiling commands", async () => { let lines: string[] = [] diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts index 720fb427a5..47245ac6e6 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts @@ -37,6 +37,7 @@ vi.mock("vscode", () => { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { @@ -92,7 +93,7 @@ function createRealCommandStream(command: string): { stream: AsyncIterable { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts index 2d03843057..cf24a1d506 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts @@ -42,6 +42,7 @@ vi.mock("vscode", () => { eventHandlers.closeTerminal = handler return { dispose: vi.fn() } }), + onDidChangeTerminalShellIntegration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, ThemeIcon: class ThemeIcon { constructor(id: string) { diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts new file mode 100644 index 0000000000..68a5876a35 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -0,0 +1,694 @@ +// npx vitest run src/integrations/terminal/__tests__/TerminalProfile.spec.ts + +import { existsSync } from "fs" + +import * as vscode from "vscode" + +import { Terminal } from "../Terminal" +import { TerminalRegistry } from "../TerminalRegistry" +import { ShellIntegrationManager } from "../ShellIntegrationManager" + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs", () => ({ + existsSync: vi.fn(() => false), +})) + +const mockedExistsSync = existsSync as unknown as ReturnType + +describe("Terminal VS Code terminal profile (#277)", () => { + // VS Code's getConfiguration/createTerminal are overloaded, so the precise + // spy MockInstance type isn't worth fighting in a test — `any` keeps it simple. + let getConfigurationSpy: any + let createTerminalSpy: any + + const mockTerminal = () => + ({ + exitStatus: undefined, + name: "Roo Code", + processId: Promise.resolve(123), + creationOptions: {}, + state: { isInteractedWith: true }, + dispose: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + sendText: vi.fn(), + shellIntegration: { executeCommand: vi.fn() }, + }) as any + + // Helper to stub `terminal.integrated.profiles.` config reads. + const stubProfiles = ( + profilesByPlatform: Record, + workspaceProfilesByPlatform: Record = {}, + ) => { + getConfigurationSpy = vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (platformKey: string) => ({ + defaultValue: profilesByPlatform[platformKey], + workspaceValue: workspaceProfilesByPlatform[platformKey], + }), + } as any + } + + return { + get: (_key: string, defaultValue?: unknown) => defaultValue, + inspect: () => undefined, + } as any + }) + } + + beforeEach(() => { + createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal()) + // Default: explicit profile paths exist unless a test says otherwise. + mockedExistsSync.mockReset() + mockedExistsSync.mockReturnValue(true) + // Reset to default (unset) before each test. + Terminal.setTerminalProfile(undefined) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + + describe("getTerminalProfile / setTerminalProfile", () => { + it("defaults to undefined", () => { + expect(Terminal.getTerminalProfile()).toBeUndefined() + }) + + it("stores a profile name", () => { + Terminal.setTerminalProfile("Git Bash") + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + }) + + it("treats empty/whitespace strings as unset (default behavior)", () => { + Terminal.setTerminalProfile("Git Bash") + Terminal.setTerminalProfile("") + expect(Terminal.getTerminalProfile()).toBeUndefined() + + Terminal.setTerminalProfile(" ") + expect(Terminal.getTerminalProfile()).toBeUndefined() + }) + }) + + describe("getConfiguredProfiles / getAvailableProfileNames", () => { + it("merges default and global profiles while ignoring workspace profiles", () => { + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: { bash: { path: "/bin/bash" } }, + globalValue: { zsh: { path: "/bin/zsh" } }, + workspaceValue: { malicious: { path: "/workspace/malicious-shell" } }, + }), + } as any + } + + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.getConfiguredProfiles("linux")).toEqual({ + bash: { path: "/bin/bash" }, + zsh: { path: "/bin/zsh" }, + }) + }) + + it("returns sorted names for profiles with resolvable paths only", () => { + stubProfiles({ + linux: { + zsh: { path: "/bin/zsh" }, + PowerShell: { source: "PowerShell" }, + disabled: null, + bash: { path: "/bin/bash" }, + missing: { path: "/missing/bash" }, + }, + }) + mockedExistsSync.mockImplementation((profilePath: string) => profilePath !== "/missing/bash") + + expect(Terminal.getAvailableProfileNames("linux")).toEqual(["bash", "zsh"]) + }) + + it("excludes cmd.exe profiles on Windows (shell integration unsupported)", () => { + stubProfiles({ + windows: { + "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, + PowerShell: { path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }, + }, + }) + + expect(Terminal.getAvailableProfileNames("win32")).toEqual(["PowerShell"]) + }) + + describe("isCmdExe", () => { + it.each([ + ["C:\\Windows\\System32\\cmd.exe", true], + ["C:\\WINDOWS\\SYSTEM32\\CMD.EXE", true], + ["/mnt/c/Windows/System32/cmd.exe", true], + ["/bin/bash", false], + ["pwsh.exe", false], + ["cmd", false], + ])("isCmdExe(%s) === %s", (input, expected) => { + expect(Terminal.isCmdExe(input)).toBe(expected) + }) + }) + + describe("isPowerShell", () => { + it.each([ + ["C:\\Program Files\\PowerShell\\pwsh.exe", true], + ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", true], + ["/usr/bin/pwsh", true], + ["C:\\Tools\\PowerShell Wrapper\\bash.exe", false], + ["/bin/bash", false], + ])("isPowerShell(%s) === %s", (input, expected) => { + expect(Terminal.isPowerShell(input)).toBe(expected) + }) + }) + + describe("isActiveShellCmdExe", () => { + it("returns false on non-Windows platforms", () => { + expect(Terminal.isActiveShellCmdExe("linux")).toBe(false) + expect(Terminal.isActiveShellCmdExe("darwin")).toBe(false) + }) + + it("returns true when profile override resolves to cmd.exe", () => { + stubProfiles({ windows: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } } }) + Terminal.setTerminalProfile("Command Prompt") + expect(Terminal.isActiveShellCmdExe("win32")).toBe(true) + }) + + it("returns false when profile override resolves to a non-cmd shell", () => { + stubProfiles({ windows: { PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } } }) + Terminal.setTerminalProfile("PowerShell") + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns true when no override and default profile is cmd.exe", () => { + Terminal.setTerminalProfile(undefined) + stubProfiles({ windows: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } } }) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" } }, + globalValue: undefined, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "Command Prompt" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(true) + }) + + it("returns false when no override and default profile is PowerShell", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } }, + globalValue: undefined, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "PowerShell" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns false when no override and no default profile configured", () => { + Terminal.setTerminalProfile(undefined) + stubProfiles({}) + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("ignores a workspace default-profile override", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: { + PowerShell: { path: "C:\\Program Files\\PowerShell\\pwsh.exe" }, + "Command Prompt": { path: "C:\\Windows\\System32\\cmd.exe" }, + }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: () => ({ + defaultValue: "PowerShell", + workspaceValue: "Command Prompt", + }), + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + + it("returns false when the configured default profile entry is missing", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { inspect: () => ({ defaultValue: {} }) } as any + } + if (section === "terminal.integrated") { + return { inspect: () => ({ defaultValue: "Deleted Profile" }) } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellCmdExe("win32")).toBe(false) + }) + }) + + describe("isActiveShellPowerShell", () => { + it("returns false on non-Windows platforms", () => { + expect(Terminal.isActiveShellPowerShell("linux")).toBe(false) + expect(Terminal.isActiveShellPowerShell("darwin")).toBe(false) + }) + + it("returns true when a custom-named profile resolves to pwsh.exe", () => { + stubProfiles({ windows: { "My Terminal": { path: "C:\\Program Files\\PowerShell\\pwsh.exe" } } }) + + Terminal.setTerminalProfile("My Terminal") + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + + it("returns false when a PowerShell-named profile resolves to a non-PowerShell shell", () => { + stubProfiles({ windows: { "PowerShell Wrapper": { path: "C:\\Program Files\\Git\\bin\\bash.exe" } } }) + + Terminal.setTerminalProfile("PowerShell Wrapper") + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(false) + }) + + it("returns true when no override and the default profile resolves to powershell.exe", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { + "Custom PS": { + path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "Custom PS" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + + it("recognizes source-only PowerShell default profiles", () => { + Terminal.setTerminalProfile(undefined) + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: (_key: string) => ({ + defaultValue: { PowerShell: { source: "PowerShell" } }, + }), + } as any + } + if (section === "terminal.integrated") { + return { + inspect: (key: string) => + key === "defaultProfile.windows" ? { defaultValue: "PowerShell" } : undefined, + } as any + } + return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any + }) + + expect(Terminal.isActiveShellPowerShell("win32")).toBe(true) + }) + }) + }) + + describe("getProfileShell", () => { + it("returns undefined when no profile is configured (default behavior preserved)", () => { + stubProfiles({}) + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("resolves a Windows Git Bash profile to its shell path and args", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: "C:\\Program Files\\Git\\bin\\bash.exe", + args: ["--login", "-i"], + }, + }, + }) + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toEqual({ + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + shellArgs: ["--login", "-i"], + }) + }) + + it("preserves the profile's env and sanitizes non-string/null values", () => { + stubProfiles({ + linux: { + "Custom Bash": { + path: "/bin/bash", + env: { + LANG: "en_US.UTF-8", + UNSET_ME: null, + BAD: 123, + BASH_ENV: "/tmp/bash-init", + ENV: "/tmp/sh-init", + PROMPT_COMMAND: "echo broken", + ZDOTDIR: "/tmp/profile", + LD_PRELOAD: "/tmp/inject.so", + LD_LIBRARY_PATH: "/tmp/lib", + DYLD_INSERT_LIBRARIES: "/tmp/inject.dylib", + DYLD_LIBRARY_PATH: "/tmp/dylib", + }, + }, + }, + }) + + Terminal.setTerminalProfile("Custom Bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: undefined, + // `null` is preserved (unsets the var); unsafe and non-string values are dropped. + env: { LANG: "en_US.UTF-8", UNSET_ME: null }, + }) + }) + + it("picks the first existing path candidate when path is an array", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\Program Files\\Git\\bin\\bash.exe"], + }, + }, + }) + // Only the second candidate exists on disk; VS Code would pick it. + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Program Files\\Git\\bin\\bash.exe") + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toEqual({ + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + shellArgs: undefined, + }) + }) + + it("falls back to default when none of the path candidates exist", () => { + stubProfiles({ + windows: { + "Git Bash": { + path: ["C:\\missing\\bash.exe", "C:\\also-missing\\bash.exe"], + }, + }, + }) + mockedExistsSync.mockReturnValue(false) + + Terminal.setTerminalProfile("Git Bash") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("wraps a string args value into an array", () => { + stubProfiles({ + linux: { + bash: { path: "/bin/bash", args: "-l" }, + }, + }) + + Terminal.setTerminalProfile("bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: ["-l"], + }) + }) + + it("drops non-string args array entries", () => { + stubProfiles({ + linux: { + bash: { path: "/bin/bash", args: ["-l", 42, null] }, + }, + }) + + Terminal.setTerminalProfile("bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/bin/bash", + shellArgs: ["-l"], + }) + }) + + it("reads the osx profile section on darwin", () => { + stubProfiles({ + osx: { zsh: { path: "/bin/zsh" } }, + }) + + Terminal.setTerminalProfile("zsh") + + expect(Terminal.getProfileShell("darwin")).toEqual({ + shellPath: "/bin/zsh", + shellArgs: undefined, + }) + }) + + it("falls back to default when the configured profile is not found", () => { + stubProfiles({ windows: { PowerShell: { path: "pwsh.exe" } } }) + + Terminal.setTerminalProfile("Nonexistent") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("falls back to default when the profile has no resolvable path (source-only profile)", () => { + stubProfiles({ windows: { PowerShell: { source: "PowerShell" } } }) + + Terminal.setTerminalProfile("PowerShell") + + expect(Terminal.getProfileShell("win32")).toBeUndefined() + }) + + it("resolves profiles defined only in user/global settings", () => { + getConfigurationSpy = vi + .spyOn(vscode.workspace, "getConfiguration") + .mockImplementation((section?: string) => { + if (section === "terminal.integrated.profiles") { + return { + inspect: () => ({ + defaultValue: undefined, + globalValue: { "User Bash": { path: "/usr/bin/bash" } }, + workspaceValue: { "User Bash": { path: "/workspace/bash" } }, + }), + } as any + } + + return { + get: (_key: string, defaultValue?: unknown) => defaultValue, + inspect: () => undefined, + } as any + }) + + Terminal.setTerminalProfile("User Bash") + + expect(Terminal.getProfileShell("linux")).toEqual({ + shellPath: "/usr/bin/bash", + shellArgs: undefined, + }) + }) + }) + + describe("resolveProfilePath", () => { + it("resolves a bare executable name through PATH", () => { + mockedExistsSync.mockImplementation((p: string) => p === "/usr/local/bin/fish") + + expect(Terminal.resolveProfilePath("fish", "linux", { PATH: "/usr/bin:/usr/local/bin" })).toBe( + "/usr/local/bin/fish", + ) + }) + + it("returns undefined when an executable cannot be found", () => { + mockedExistsSync.mockReturnValue(false) + + expect(Terminal.resolveProfilePath("/missing/bash", "linux", { PATH: "/usr/bin" })).toBeUndefined() + }) + + it("ignores disabled or missing profile paths", () => { + expect(Terminal.resolveProfilePath(null, "linux", { PATH: "/usr/bin" })).toBeUndefined() + expect(Terminal.resolveProfilePath(undefined, "linux", { PATH: "/usr/bin" })).toBeUndefined() + }) + + it("resolves a bare Windows executable name through PATH and PATHEXT", () => { + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Tools\\pwsh.EXE") + + expect( + Terminal.resolveProfilePath("pwsh", "win32", { + PATH: "C:\\Windows\\System32;C:\\Tools", + PATHEXT: ".COM;.EXE", + }), + ).toBe("C:\\Tools\\pwsh.EXE") + }) + + it("resolves a bare Windows executable name through Path when PATH is absent", () => { + mockedExistsSync.mockImplementation((p: string) => p === "C:\\Tools\\pwsh.EXE") + + expect( + Terminal.resolveProfilePath("pwsh", "win32", { + Path: "C:\\Windows\\System32;C:\\Tools", + PATHEXT: ".COM;.EXE", + }), + ).toBe("C:\\Tools\\pwsh.EXE") + }) + }) + + describe("createTerminal integration", () => { + afterEach(() => { + TerminalRegistry["terminals"] = [] + }) + + it("does NOT pass shellPath/shellArgs when no profile is configured", () => { + stubProfiles({}) + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBeUndefined() + expect(options.shellArgs).toBeUndefined() + }) + + it("passes the resolved shellPath/shellArgs when a profile is configured", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Git Bash": { path: "/usr/bin/bash", args: ["-i"] }, + }, + }) + + Terminal.setTerminalProfile("Git Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBe("/usr/bin/bash") + expect(options.shellArgs).toEqual(["-i"]) + }) + + it("falls back to VS Code defaults when a configured profile disappears", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Git Bash": { path: "/missing/bash" }, + }, + }) + mockedExistsSync.mockReturnValue(false) + + Terminal.setTerminalProfile("Git Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.shellPath).toBeUndefined() + expect(options.shellArgs).toBeUndefined() + }) + + it("merges safe profile env while preserving Zoo Code shell-integration vars", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + "Custom Bash": { + path: "/usr/bin/bash", + env: { LANG: "en_US.UTF-8", PAGER: "less", ZDOTDIR: "/tmp/profile" }, + }, + }, + }) + + Terminal.setTerminalProfile("Custom Bash") + TerminalRegistry.createTerminal("/test/path", "vscode") + + const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions + expect(options.env).toMatchObject({ + LANG: "en_US.UTF-8", + PAGER: process.platform === "win32" ? "" : "cat", + ROO_ACTIVE: "true", + VTE_VERSION: "0", + }) + expect(options.env?.ZDOTDIR).toBeUndefined() + }) + }) + + describe("ZDOTDIR injection guard", () => { + let zshInitTmpDirSpy: any + + beforeEach(() => { + zshInitTmpDirSpy = vi + .spyOn(ShellIntegrationManager, "zshInitTmpDir") + .mockReturnValue("/tmp/roo-zdotdir-test") + Terminal.setTerminalZdotdir(true) + }) + + afterEach(() => { + Terminal.setTerminalZdotdir(false) + Terminal.setTerminalProfile(undefined) + TerminalRegistry["terminals"] = [] + vi.restoreAllMocks() + }) + + it("sets ZDOTDIR when zdotdir is enabled and no profile is configured", () => { + stubProfiles({}) + const env = Terminal.getEnv() + expect(zshInitTmpDirSpy).toHaveBeenCalledTimes(1) + expect(env.ZDOTDIR).toBe("/tmp/roo-zdotdir-test") + }) + + it("skips ZDOTDIR when zdotdir is enabled but a profile is configured", () => { + stubProfiles({ + [Terminal.getPlatformProfileKey(process.platform)]: { + zsh: { path: "/bin/zsh" }, + }, + }) + Terminal.setTerminalProfile("zsh") + const env = Terminal.getEnv() + expect(zshInitTmpDirSpy).not.toHaveBeenCalled() + expect(env.ZDOTDIR).toBeUndefined() + }) + }) +}) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index 5039b2a326..586c3abd57 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -1,6 +1,8 @@ // npx vitest run src/integrations/terminal/__tests__/TerminalRegistry.spec.ts import * as vscode from "vscode" +import { ExecaTerminal } from "../ExecaTerminal" +import { ShellIntegrationManager } from "../ShellIntegrationManager" import { Terminal } from "../Terminal" import { TerminalRegistry } from "../TerminalRegistry" @@ -14,11 +16,13 @@ describe("TerminalRegistry", () => { let mockCreateTerminal: any beforeEach(() => { + TerminalRegistry["terminals"] = [] + Terminal.setTerminalProfile(undefined) mockCreateTerminal = vi.spyOn(vscode.window, "createTerminal").mockImplementation( (...args: any[]) => ({ exitStatus: undefined, - name: "Roo Code", + name: "Zoo Code", processId: Promise.resolve(123), creationOptions: {}, state: { @@ -36,13 +40,19 @@ describe("TerminalRegistry", () => { ) }) + afterEach(() => { + TerminalRegistry["terminals"] = [] + Terminal.setTerminalProfile(undefined) + vi.restoreAllMocks() + }) + describe("createTerminal", () => { it("creates terminal with PAGER set appropriately for platform", () => { TerminalRegistry.createTerminal("/test/path", "vscode") expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -63,7 +73,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -86,7 +96,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -108,7 +118,7 @@ describe("TerminalRegistry", () => { expect(mockCreateTerminal).toHaveBeenCalledWith({ cwd: "/test/path", - name: "Roo Code", + name: "Zoo Code", iconPath: expect.any(Object), env: { PAGER, @@ -124,6 +134,77 @@ describe("TerminalRegistry", () => { }) }) + describe("getOrCreateTerminal", () => { + it("reuses an idle VS Code terminal when the selected profile is unchanged", async () => { + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(1) + }) + + it("creates a new VS Code terminal after changing from default to an override", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile("Git Bash") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("creates a new VS Code terminal after changing from an override to default", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + Terminal.setTerminalProfile("Git Bash") + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile(undefined) + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("creates a new VS Code terminal after changing between named profiles", async () => { + vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined) + Terminal.setTerminalProfile("Git Bash") + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + Terminal.setTerminalProfile("zsh") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "vscode") + + expect(second).not.toBe(first) + expect(mockCreateTerminal).toHaveBeenCalledTimes(2) + }) + + it("continues to reuse Execa terminals when the VS Code profile changes", async () => { + const first = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "execa") + + Terminal.setTerminalProfile("Git Bash") + const second = await TerminalRegistry.getOrCreateTerminal("/test/path", "task", "execa") + + expect(second).toBe(first) + }) + }) + + describe("closeIdleTerminals", () => { + it("disposes only idle VS Code terminals and cleans up their temporary zsh directories", () => { + const idle = TerminalRegistry.createTerminal("/idle", "vscode") as Terminal + const busy = TerminalRegistry.createTerminal("/busy", "vscode") as Terminal + const execa = TerminalRegistry.createTerminal("/inline", "execa") as ExecaTerminal + busy.busy = true + const cleanupSpy = vi.spyOn(ShellIntegrationManager, "zshCleanupTmpDir") + + TerminalRegistry.closeIdleTerminals() + + expect(idle.terminal.dispose).toHaveBeenCalledTimes(1) + expect(cleanupSpy).toHaveBeenCalledWith(idle.id) + expect(busy.terminal.dispose).not.toHaveBeenCalled() + expect(TerminalRegistry["terminals"]).toEqual([busy, execa]) + }) + }) + describe("releaseTerminalsForTask", () => { it("aborts a busy terminal's running process and disassociates it from the task (#245)", () => { const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") diff --git a/src/integrations/terminal/__tests__/streamUtils/bashStream.ts b/src/integrations/terminal/__tests__/streamUtils/bashStream.ts index 3bd1496d17..0f5dab1f44 100644 --- a/src/integrations/terminal/__tests__/streamUtils/bashStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/bashStream.ts @@ -40,7 +40,7 @@ export function createBashCommandStream(command: string): CommandStream { exitCode = 1 } } else { - exitCode = error.status || 1 // Use status if available, default to 1 + exitCode = error.status ?? 1 // Use status if available, default to 1 } } diff --git a/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts b/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts index c6df7f8272..a34e180f91 100644 --- a/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/cmdStream.ts @@ -25,7 +25,7 @@ export function createCmdCommandStream(command: string): CommandStream { } catch (error: any) { // Command failed - get output and exit code from error realOutput = error.stdout?.toString() || "" - exitCode = error.status || 1 + exitCode = error.status ?? 1 } // Create an async iterator for the stream diff --git a/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts b/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts index cb690eb86e..9496bf7553 100644 --- a/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts +++ b/src/integrations/terminal/__tests__/streamUtils/pwshStream.ts @@ -38,11 +38,11 @@ export function createPowerShellStream(command: string): CommandStream { } catch (error: any) { // Command failed - get output and exit code from error realOutput = error.stdout?.toString() || "" - console.error(`PowerShell command failed with status ${error.status || "unknown"}:`, error.message) + console.error(`PowerShell command failed with status ${error.status ?? "unknown"}:`, error.message) if (error.stderr) { console.error(`stderr: ${error.stderr.toString()}`) } - exitCode = error.status || 1 + exitCode = error.status ?? 1 } // Create an async iterator for the stream diff --git a/src/integrations/terminal/types.ts b/src/integrations/terminal/types.ts index a0c5cde5d5..8224875b60 100644 --- a/src/integrations/terminal/types.ts +++ b/src/integrations/terminal/types.ts @@ -5,6 +5,7 @@ export type RooTerminalProvider = "vscode" | "execa" export interface RooTerminal { provider: RooTerminalProvider id: number + reuseKey: string busy: boolean running: boolean taskId?: string @@ -25,7 +26,21 @@ export interface RooTerminalCallbacks { onCompleted: (output: string | undefined, process: RooTerminalProcess) => void | Promise onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void - onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void + onNoShellIntegration?: (details: ShellIntegrationErrorDetails, process: RooTerminalProcess) => void +} + +export interface ShellIntegrationErrorDetails { + message: string + commandSubmitted: boolean +} + +export class ShellIntegrationError extends Error { + constructor( + message: string, + public readonly commandSubmitted: boolean, + ) { + super(message) + } } export interface RooTerminalProcess extends EventEmitter { @@ -49,7 +64,7 @@ export interface RooTerminalProcessEvents { shell_execution_started: [pid: number | undefined] shell_execution_complete: [exitDetails: ExitCodeDetails] error: [error: Error] - no_shell_integration: [message: string] + no_shell_integration: [details: ShellIntegrationErrorDetails] } export interface ExitCodeDetails { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8197c858a7..5b311f8902 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,6 +183,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, writeDelayMs, showRooIgnoredFiles, enableSubfolderRules, @@ -397,6 +398,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile: terminalProfile ?? "", // "" clears a saved profile; undefined is dropped by JSON.stringify terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), @@ -864,6 +866,8 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy={terminalZshOhMy} terminalZshP10k={terminalZshP10k} terminalZdotdir={terminalZdotdir} + terminalProfile={terminalProfile} + onTerminalProfilePickerOpened={() => setChangeDetected(true)} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 07f062cc01..b265692944 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -1,7 +1,7 @@ -import { HTMLAttributes, useState, useCallback } from "react" +import { HTMLAttributes, useState, useCallback, useEffect, useId } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { vscode } from "@/utils/vscode" -import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeLink, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { buildDocLink } from "@src/utils/docLinks" import { useEvent, useMount } from "react-use" @@ -26,6 +26,8 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshOhMy?: boolean terminalZshP10k?: boolean terminalZdotdir?: boolean + terminalProfile?: string + onTerminalProfilePickerOpened?: () => void setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -36,9 +38,14 @@ type TerminalSettingsProps = HTMLAttributes & { | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "terminalProfile" > } +// Sentinel value that maps to `undefined` (use VS Code's default shell). +// The Select component cannot accept empty-string item values. +const DEFAULT_PROFILE_VALUE = "__default__" + export const TerminalSettings = ({ terminalOutputPreviewSize, terminalShellIntegrationTimeout, @@ -49,6 +56,8 @@ export const TerminalSettings = ({ terminalZshOhMy, terminalZshP10k, terminalZdotdir, + terminalProfile, + onTerminalProfilePickerOpened, setCachedStateField, className, ...props @@ -56,22 +65,34 @@ export const TerminalSettings = ({ const { t } = useAppTranslation() const [inheritEnv, setInheritEnv] = useState(true) + const [profileNames, setProfileNames] = useState([]) + const [isProfilesLoaded, setIsProfilesLoaded] = useState(false) + const profileModeId = useId() + const defaultProfileId = `${profileModeId}-default` + const overrideProfileId = `${profileModeId}-override` + const isProfileOverrideSelected = !!terminalProfile && (!isProfilesLoaded || profileNames.includes(terminalProfile)) + const isVSCodeTerminalEnabled = terminalShellIntegrationDisabled === false - useMount(() => vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" })) + useMount(() => { + vscode.postMessage({ type: "getVSCodeSetting", setting: "terminal.integrated.inheritEnv" }) + // Request the terminal profile names through a dedicated, allowlisted message + // (the extension reads the profiles and returns only sanitized names). + vscode.postMessage({ type: "requestTerminalProfiles" }) + }) const onMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "vsCodeSetting": - switch (message.setting) { - case "terminal.integrated.inheritEnv": - setInheritEnv(message.value ?? true) - break - default: - break + if (message.setting === "terminal.integrated.inheritEnv") { + setInheritEnv(message.value ?? true) } break + case "terminalProfiles": + setProfileNames(message.profiles ?? []) + setIsProfilesLoaded(true) + break default: break } @@ -79,6 +100,12 @@ export const TerminalSettings = ({ useEvent("message", onMessage) + useEffect(() => { + if (isProfilesLoaded && terminalProfile && !profileNames.includes(terminalProfile)) { + setCachedStateField("terminalProfile", undefined) + } + }, [isProfilesLoaded, profileNames, setCachedStateField, terminalProfile]) + return (
{t("settings:sections.terminal")} @@ -139,6 +166,111 @@ export const TerminalSettings = ({
+ {/* Profile override — only applies when VS Code integrated terminal is active + (shell integration enabled). Hidden in Execa/inline mode since getProfileShell() + is not wired there. */} + {isVSCodeTerminalEnabled && ( + + + + {/* Level 1: Default (recommended) */} +
+ setCachedStateField("terminalProfile", undefined)} + data-testid="terminal-profile-default-radio" + /> + + { + onTerminalProfilePickerOpened?.() + vscode.postMessage({ type: "openTerminalProfilePicker" }) + }} + data-testid="terminal-profile-configure-button"> + {t("settings:terminal.profile.configureButton")} + +
+ + {/* Level 2: Override */} +
+ { + if (!terminalProfile && profileNames.length > 0) { + setCachedStateField("terminalProfile", profileNames[0]) + } + }} + data-testid="terminal-profile-override-radio" + /> + + {profileNames.length === 0 && ( + + {t("settings:terminal.profile.noProfiles")} + + )} +
+ + {isProfileOverrideSelected && profileNames.length > 0 && ( + + )} + +
+ + + {" "} + + +
+
+ )} + - {!terminalShellIntegrationDisabled && ( + {isVSCodeTerminalEnabled && ( <> - {terminalCommandDelay ?? 50}ms + {terminalCommandDelay ?? 0}ms
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 42239e33a3..cb5dc8ec28 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 @@ -286,6 +286,7 @@ describe("SettingsView - Change Detection Fix", () => { terminalZshOhMy: false, terminalZshP10k: false, terminalZdotdir: false, + terminalProfile: undefined, writeDelayMs: 0, showRooIgnoredFiles: false, maxReadFileLine: -1, diff --git a/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx new file mode 100644 index 0000000000..350ab78b05 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/TerminalSettings.profile.spec.tsx @@ -0,0 +1,231 @@ +// npx vitest run src/components/settings/__tests__/TerminalSettings.profile.spec.tsx + +import * as React from "react" + +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import { TerminalSettings } from "../TerminalSettings" + +// Mock translation hook to echo keys +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/utils/docLinks", () => ({ + buildDocLink: () => "https://example.com", +})) + +const postMessageMock = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { postMessage: (...args: any[]) => postMessageMock(...args) }, +})) + +// Render Select as a list of buttons so we can drive onValueChange in tests. +vi.mock("@/components/ui", () => ({ + Select: ({ children, value, onValueChange, "data-testid": testId }: any) => ( +
+ {renderSelectChildren(children, onValueChange)} +
+ ), + SelectTrigger: ({ children, ...rest }: any) =>
{children}
, + SelectValue: ({ children }: any) =>
{children}
, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) =>
{children}
, + Slider: ({ value, onValueChange }: any) => ( + onValueChange([parseFloat(e.target.value)])} /> + ), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ checked, onChange, children }: any) => ( + + ), + VSCodeLink: ({ children }: any) => {children}, + VSCodeButton: ({ children, onClick, ...rest }: any) => ( + + ), +})) + +// Helper used by the Select mock to render SelectItem children as buttons. +function renderSelectChildren(children: any, onValueChange: (value: string) => void): any { + return React.Children.map(children, (child: any) => { + if (!child || typeof child !== "object") return child + const itemValue = child.props?.value ?? child.props?.["data-item-value"] + if (child.props?.children && itemValue === undefined) { + return renderSelectChildren(child.props.children, onValueChange) + } + if (itemValue !== undefined) { + return ( + + ) + } + return child + }) +} + +describe("TerminalSettings VS Code terminal profile (#277)", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + // The profile section applies to the VS Code integrated terminal (terminalShellIntegrationDisabled === false). + const setup = (terminalProfile?: string) => { + const setCachedStateField = vi.fn() + const onTerminalProfilePickerOpened = vi.fn() + render( + , + ) + return { onTerminalProfilePickerOpened, setCachedStateField } + } + + it("requests the terminal profile names on mount via the allowlisted message", () => { + setup() + const types = postMessageMock.mock.calls.map((c) => c[0]?.type) + expect(types).toContain("requestTerminalProfiles") + }) + + it("shows the default radio selected and no dropdown when no profile is set", () => { + setup() + const defaultRadio = screen.getByTestId("terminal-profile-default-radio") + expect(defaultRadio).toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + }) + + it("shows the override radio selected and dropdown when a profile is set and profiles are available", () => { + setup("Git Bash") + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Git Bash", "zsh"] }, + }), + ) + }) + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).toBeChecked() + expect(screen.getByTestId("terminal-profile-dropdown")).toBeInTheDocument() + }) + + it("keeps a saved profile selected while profile names are loading", () => { + const { setCachedStateField } = setup("Git Bash") + + expect(screen.getByTestId("terminal-profile-override-radio")).toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + expect(setCachedStateField).not.toHaveBeenCalled() + }) + + it("falls back to the default radio and clears an unavailable saved profile after profiles load", () => { + const { setCachedStateField } = setup("Git Bash") + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Command Prompt"] }, + }), + ) + }) + + expect(screen.getByTestId("terminal-profile-default-radio")).toBeChecked() + expect(screen.getByTestId("terminal-profile-override-radio")).not.toBeChecked() + expect(screen.queryByTestId("terminal-profile-dropdown")).not.toBeInTheDocument() + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("uses instance-local radio groups", () => { + render( + <> + + + , + ) + + const defaultRadios = screen.getAllByTestId("terminal-profile-default-radio") + expect(defaultRadios[0]).toBeChecked() + expect(defaultRadios[1]).toBeChecked() + expect(defaultRadios[0]).not.toHaveAttribute("name", defaultRadios[1].getAttribute("name")) + }) + + it("populates the dropdown from received profile names and selecting one sets the profile", () => { + const { setCachedStateField } = setup("Git Bash") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["Git Bash", "zsh"] }, + }), + ) + }) + + fireEvent.click(screen.getByTestId("option-zsh")) + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", "zsh") + }) + + it("clicking default radio sets terminalProfile to undefined", () => { + const { setCachedStateField } = setup("Git Bash") + fireEvent.click(screen.getByTestId("terminal-profile-default-radio")) + expect(setCachedStateField).toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("renders the native profile configure button and posts openTerminalProfilePicker when clicked", () => { + const { onTerminalProfilePickerOpened, setCachedStateField } = setup("Git Bash") + const btn = screen.getByTestId("terminal-profile-configure-button") + expect(btn).toBeInTheDocument() + fireEvent.click(btn) + expect(onTerminalProfilePickerOpened).toHaveBeenCalledTimes(1) + expect(postMessageMock).toHaveBeenCalledWith({ type: "openTerminalProfilePicker" }) + expect(setCachedStateField).not.toHaveBeenCalledWith("terminalProfile", undefined) + }) + + it("shows picker section when VS Code integrated terminal is active (shell integration enabled)", () => { + render() + expect(screen.getByTestId("terminal-profile-default-radio")).toBeInTheDocument() + }) + + it("hides picker section when inline/Execa execution is active (shell integration disabled)", () => { + render() + expect(screen.queryByTestId("terminal-profile-default-radio")).not.toBeInTheDocument() + }) + + it("hides picker section when terminalShellIntegrationDisabled is undefined (defaults to inline mode)", () => { + render() + expect(screen.queryByTestId("terminal-profile-default-radio")).not.toBeInTheDocument() + expect(screen.queryByText("settings:terminal.inheritEnv.label")).not.toBeInTheDocument() + }) + + it("shows the command delay default as 0ms", () => { + render() + expect(screen.getByText("0ms")).toBeInTheDocument() + }) + + it("disables override radio and shows hint when no profiles are available", () => { + setup() + // No terminalProfiles message dispatched → profileNames stays [] + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).toBeDisabled() + expect(screen.getByTestId("terminal-profile-no-profiles-hint")).toBeInTheDocument() + }) + + it("enables override radio after profiles are received", () => { + setup() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "terminalProfiles", profiles: ["zsh"] }, + }), + ) + }) + const overrideRadio = screen.getByTestId("terminal-profile-override-radio") + expect(overrideRadio).not.toBeDisabled() + expect(screen.queryByTestId("terminal-profile-no-profiles-hint")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index f81de054c7..3c6698f3d1 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -84,6 +84,7 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalShellIntegrationDisabled: (value: boolean) => void terminalZdotdir?: boolean setTerminalZdotdir: (value: boolean) => void + terminalProfile?: string setTtsEnabled: (value: boolean) => void setTtsSpeed: (value: number) => void setEnableCheckpoints: (value: boolean) => void @@ -235,6 +236,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalZshOhMy: false, // Default Oh My Zsh integration setting terminalZshP10k: false, // Default Powerlevel10k integration setting terminalZdotdir: false, // Default ZDOTDIR handling setting + terminalProfile: undefined, // Default VS Code terminal profile (use VS Code default) historyPreviewCollapsed: false, // Initialize the new state (default to expanded) reasoningBlockCollapsed: true, // Default to collapsed enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index e182578114..5a17af5d07 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Hereta variables d'entorn", "description": "Activa per heretar variables d'entorn del procés pare de VS Code. <0>Aprèn-ne més" + }, + "profile": { + "label": "Substitució del terminal de Zoo Code", + "default": "Utilitza el perfil predeterminat de VS Code (recomanat)", + "description": "Per defecte, Zoo Code utilitza la shell que VS Code té configurada. Seleccioneu Substituir per triar un perfil de shell amb ruta explícita exposat per VS Code. Els perfils de només font (p. ex., l'entrada integrada de PowerShell) no es poden llistar aquí. <0>Més informació", + "overrideLabel": "Substituir la shell per a Zoo Code", + "configureButton": "Trieu el perfil predeterminat a VS Code", + "noProfiles": "(no s'han trobat perfils amb ruta a terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 4fc253c34b..a7b3ef335e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Umgebungsvariablen erben", "description": "Schalte dies ein, um Umgebungsvariablen vom übergeordneten VS Code-Prozess zu erben. <0>Mehr erfahren" + }, + "profile": { + "label": "Zoo Code Terminal-Überschreibung", + "default": "VS Code-Standardprofil verwenden (empfohlen)", + "description": "Standardmäßig verwendet Zoo Code die in VS Code konfigurierte Shell. Wähle Überschreiben, um ein pfadbasiertes Shell-Profil zu wählen, das VS Code bereitstellt. Quellenbasierte Profile (z. B. der integrierte PowerShell-Eintrag) können hier nicht aufgelistet werden. <0>Mehr erfahren", + "overrideLabel": "Shell für Zoo Code überschreiben", + "configureButton": "Standardprofil in VS Code auswählen", + "noProfiles": "(keine pfadbasierten Profile in terminal.integrated.profiles gefunden)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index faacde3980..02c9c5634d 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -816,6 +816,14 @@ "inheritEnv": { "label": "Inherit environment variables", "description": "Turn this on to inherit environment variables from the parent VS Code process. <0>Learn more" + }, + "profile": { + "label": "Zoo Code terminal override", + "default": "Use VS Code default profile (recommended)", + "overrideLabel": "Override shell for Zoo Code", + "configureButton": "Choose default profile in VS Code", + "noProfiles": "(no path-based profiles found in terminal.integrated.profiles)", + "description": "By default Zoo Code uses whatever shell VS Code is configured to use. Select Override to pick a path-based shell profile exposed by VS Code. Source-only profiles (e.g. the built-in PowerShell entry) cannot be listed here. <0>Learn more" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index a365284878..053bdfd14c 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Heredar variables de entorno", "description": "Activa para heredar variables de entorno del proceso padre de VS Code. <0>Más información" + }, + "profile": { + "label": "Anulación del terminal de Zoo Code", + "default": "Usar perfil predeterminado de VS Code (recomendado)", + "description": "De forma predeterminada, Zoo Code usa la shell que VS Code tiene configurada. Selecciona Anular para elegir un perfil de shell con ruta expuesto por VS Code. Los perfiles solo de fuente (p. ej., la entrada integrada de PowerShell) no se pueden listar aquí. <0>Más información", + "overrideLabel": "Anular shell para Zoo Code", + "configureButton": "Elegir perfil predeterminado en VS Code", + "noProfiles": "(no se encontraron perfiles con ruta en terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index fa946b9435..658755e1ec 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Hériter des variables d'environnement", "description": "Activez pour hériter des variables d'environnement du processus parent VS Code. <0>En savoir plus" + }, + "profile": { + "label": "Remplacement du terminal Zoo Code", + "default": "Utiliser le profil par défaut VS Code (recommandé)", + "description": "Par défaut, Zoo Code utilise le shell configuré dans VS Code. Sélectionnez Remplacer pour choisir un profil shell avec chemin exposé par VS Code. Les profils source uniquement (ex. : l'entrée PowerShell intégrée) ne peuvent pas être listés ici. <0>En savoir plus", + "overrideLabel": "Remplacer le shell pour Zoo Code", + "configureButton": "Choisir le profil par défaut dans VS Code", + "noProfiles": "(aucun profil avec chemin trouvé dans terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2501871e0b..fe96351910 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "पर्यावरण चर विरासत में लें", "description": "पैरेंट VS Code प्रोसेस से पर्यावरण चर विरासत में लेने के लिए इसे चालू करें। <0>अधिक जानें" + }, + "profile": { + "label": "Zoo Code टर्मिनल ओवरराइड", + "default": "VS Code डिफ़ॉल्ट प्रोफ़ाइल उपयोग करें (अनुशंसित)", + "description": "डिफ़ॉल्ट रूप से Zoo Code VS Code में कॉन्फ़िगर की गई शेल का उपयोग करता है। VS Code द्वारा प्रदर्शित पथ-आधारित शेल प्रोफ़ाइल चुनने के लिए ओवरराइड चुनें। केवल-स्रोत प्रोफ़ाइल (जैसे, अंतर्निर्मित PowerShell प्रविष्टि) यहाँ सूचीबद्ध नहीं किए जा सकते। <0>अधिक जानें", + "overrideLabel": "Zoo Code के लिए शेल ओवरराइड करें", + "configureButton": "VS Code में डिफ़ॉल्ट प्रोफ़ाइल चुनें", + "noProfiles": "(terminal.integrated.profiles में कोई पथ-आधारित प्रोफ़ाइल नहीं मिली)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index ee183e93af..e4cf17c6a8 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Warisi variabel lingkungan", "description": "Aktifkan untuk mewarisi variabel lingkungan dari proses induk VS Code. <0>Pelajari lebih lanjut" + }, + "profile": { + "label": "Penimpaan terminal Zoo Code", + "default": "Gunakan profil default VS Code (direkomendasikan)", + "description": "Secara default Zoo Code menggunakan shell yang dikonfigurasi VS Code. Pilih Timpa untuk memilih profil shell berbasis jalur yang diekspos oleh VS Code. Profil hanya sumber (mis., entri PowerShell bawaan) tidak dapat tercantum di sini. <0>Pelajari lebih lanjut", + "overrideLabel": "Timpa shell untuk Zoo Code", + "configureButton": "Pilih profil default di VS Code", + "noProfiles": "(tidak ada profil berbasis jalur di terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 2e358e5d96..baf2c2fbd5 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Eredita variabili d'ambiente", "description": "Attiva per ereditare le variabili d'ambiente dal processo padre di VS Code. <0>Scopri di più" + }, + "profile": { + "label": "Override terminale Zoo Code", + "default": "Usa il profilo predefinito di VS Code (consigliato)", + "description": "Per impostazione predefinita Zoo Code usa la shell configurata in VS Code. Seleziona Override per scegliere un profilo shell con percorso esposto da VS Code. I profili solo sorgente (es. la voce PowerShell integrata) non possono essere elencati qui. <0>Ulteriori informazioni", + "overrideLabel": "Sostituisci shell per Zoo Code", + "configureButton": "Scegli il profilo predefinito in VS Code", + "noProfiles": "(nessun profilo con percorso trovato in terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e6b59fdf55..14e41007e1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "環境変数を継承", "description": "親VS Codeプロセスから環境変数を継承するには、これをオンにします。<0>詳細情報" + }, + "profile": { + "label": "Zoo Code ターミナルの上書き", + "default": "VS Code のデフォルトプロファイルを使用する(推奨)", + "description": "デフォルトでは Zoo Code は VS Code に設定されたシェルを使用します。VS Code が公開するパスベースのシェルプロファイルを選択するには「上書き」を選択してください。ソースのみのプロファイル(例:組み込みの PowerShell エントリ)はここに表示できません。 <0>詳細を見る", + "overrideLabel": "Zoo Code 用シェルを上書き", + "configureButton": "VS Code でデフォルトプロファイルを選択", + "noProfiles": "(terminal.integrated.profiles にパスベースのプロファイルが見つかりません)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 58c4e34828..e56f9a83db 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "환경 변수 상속", "description": "부모 VS Code 프로세���에서 환경 변수를 상속하려면 이 기능을 켜십시오. <0>자세히 알아보기" + }, + "profile": { + "label": "Zoo Code 터미널 재정의", + "default": "VS Code 기본 프로필 사용 (권장)", + "description": "기본적으로 Zoo Code는 VS Code에 구성된 쉘을 사용합니다. VS Code가 노출하는 경로 기반 쉘 프로필을 선택하려면 재정의를 선택하세요. 소스 전용 프로필(예: 내장 PowerShell 항목)은 여기에 나열할 수 없습니다. <0>자세히 알아보기", + "overrideLabel": "Zoo Code용 쉘 재정의", + "configureButton": "VS Code에서 기본 프로필 선택", + "noProfiles": "(terminal.integrated.profiles에 경로 기반 프로필이 없습니다)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2e98740174..4acc630dda 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Omgevingsvariabelen overnemen", "description": "Schakel in om omgevingsvariabelen over te nemen van het bovenliggende VS Code-proces. <0>Meer informatie" + }, + "profile": { + "label": "Zoo Code terminal-overschrijving", + "default": "VS Code standaardprofiel gebruiken (aanbevolen)", + "description": "Standaard gebruikt Zoo Code de shell die in VS Code is geconfigureerd. Selecteer Overschrijven om een shell-profiel met pad te kiezen dat VS Code beschikbaar stelt. Uitsluitend op bron gebaseerde profielen (bijv. de ingebouwde PowerShell-vermelding) kunnen hier niet worden vermeld. <0>Meer informatie", + "overrideLabel": "Shell voor Zoo Code overschrijven", + "configureButton": "Standaardprofiel kiezen in VS Code", + "noProfiles": "(geen profielen met pad gevonden in terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c9ee66c354..7a46486fbf 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Dziedzicz zmienne środowiskowe", "description": "Włącz, aby dziedziczyć zmienne środowiskowe z procesu nadrzędnego VS Code. <0>Dowiedz się więcej" + }, + "profile": { + "label": "Nadpisanie terminala Zoo Code", + "default": "Użyj domyślnego profilu VS Code (zalecane)", + "description": "Domyślnie Zoo Code używa powłoki skonfigurowanej w VS Code. Wybierz Nadpisanie, aby wybrać profil powłoki ze ścieżką udostępniony przez VS Code. Profile tylko ze źródłem (np. wbudowany wpis PowerShell) nie mogą być tutaj wyświetlone. <0>Dowiedz się więcej", + "overrideLabel": "Nadpisz powłokę dla Zoo Code", + "configureButton": "Wybierz domyślny profil w VS Code", + "noProfiles": "(brak profili ze ścieżką w terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index de4d8550a6..cf165d9d3c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Herdar variáveis de ambiente", "description": "Ative isso para herdar variáveis de ambiente do processo pai do VS Code. <0>Saiba mais" + }, + "profile": { + "label": "Substituição de terminal do Zoo Code", + "default": "Usar perfil padrão do VS Code (recomendado)", + "description": "Por padrão, o Zoo Code usa o shell configurado no VS Code. Selecione Substituir para escolher um perfil de shell com caminho exposto pelo VS Code. Perfis somente de fonte (ex.: a entrada integrada do PowerShell) não podem ser listados aqui. <0>Saiba mais", + "overrideLabel": "Substituir shell para o Zoo Code", + "configureButton": "Escolher perfil padrão no VS Code", + "noProfiles": "(nenhum perfil com caminho encontrado em terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 67baa866d4..3d82639fbf 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Наследовать переменные среды", "description": "Включите для наследования переменных среды от родительского процесса VS Code. <0>Подробнее" + }, + "profile": { + "label": "Переопределение терминала Zoo Code", + "default": "Использовать профиль VS Code по умолчанию (рекомендуется)", + "description": "По умолчанию Zoo Code использует оболочку, настроенную в VS Code. Выберите Переопределить, чтобы указать профиль оболочки с путём, предоставленный VS Code. Профили только с источником (например, встроенная запись PowerShell) не могут быть перечислены здесь. <0>Подробнее", + "overrideLabel": "Переопределить оболочку для Zoo Code", + "configureButton": "Выбрать профиль по умолчанию в VS Code", + "noProfiles": "(профили с путём в terminal.integrated.profiles не найдены)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 6196041862..230a88ee29 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Ortam değişkenlerini devral", "description": "Ana VS Code işleminden ortam değişkenlerini devralmak için bunu açın. <0>Daha fazla bilgi edinin" + }, + "profile": { + "label": "Zoo Code terminal geçersiz kılma", + "default": "VS Code varsayılan profilini kullan (önerilir)", + "description": "Varsayılan olarak Zoo Code, VS Code'da yapılandırılmış kabuğu kullanır. VS Code'un sunduğu yol tabanlı bir kabuk profili seçmek için Geçersiz Kıl'ı seçin. Yalnızca kaynak profiller (örn. yerleşik PowerShell girişi) burada listelenemez. <0>Daha fazla bilgi", + "overrideLabel": "Zoo Code için kabuk geçersiz kıl", + "configureButton": "VS Code'da varsayılan profili seç", + "noProfiles": "(terminal.integrated.profiles'da yol tabanlı profil bulunamadı)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e84f6b9619..d957b7b51e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "Kế thừa biến môi trường", "description": "Bật tính năng này để kế thừa các biến môi trường từ quy trình mẹ của VS Code. <0>Tìm hiểu thêm" + }, + "profile": { + "label": "Ghi đè terminal Zoo Code", + "default": "Dùng hồ sơ mặc định của VS Code (khuyến nghị)", + "description": "Theo mặc định Zoo Code sử dụng shell được cấu hình trong VS Code. Chọn Ghi đè để chọn hồ sơ shell dựa trên đường dẫn được VS Code cung cấp. Các hồ sơ chỉ nguồn (ví dụ: mục PowerShell tích hợp) không thể được liệt kê ở đây. <0>Tìm hiểu thêm", + "overrideLabel": "Ghi đè shell cho Zoo Code", + "configureButton": "Chọn hồ sơ mặc định trong VS Code", + "noProfiles": "(không tìm thấy hồ sơ dựa trên đường dẫn trong terminal.integrated.profiles)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8f3c137329..885b26aafd 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -748,6 +748,14 @@ "inheritEnv": { "label": "继承环境变量", "description": "启用此选项以从父 VS Code 进程继承环境变量。<0>了解更多" + }, + "profile": { + "label": "Zoo Code 终端覆盖", + "default": "使用 VS Code 默认配置文件(推荐)", + "description": "默认情况下,Zoo Code 使用 VS Code 配置的 Shell。选择覆盖可从 VS Code 公开的路径型 Shell 配置文件中选取。仅含来源的配置文件(如内置的 PowerShell 条目)无法在此列出。 <0>了解更多", + "overrideLabel": "为 Zoo Code 覆盖 Shell", + "configureButton": "在 VS Code 中选择默认配置文件", + "noProfiles": "(在 terminal.integrated.profiles 中未找到路径型配置文件)" } }, "advancedSettings": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 1ebaff0e53..6de554cfee 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -763,6 +763,14 @@ "inheritEnv": { "label": "繼承環境變數", "description": "啟用此選項以從父 VS Code 程序繼承環境變數。<0>了解更多" + }, + "profile": { + "label": "Zoo Code 終端機覆寫", + "default": "使用 VS Code 預設設定檔(建議)", + "description": "預設情況下,Zoo Code 使用 VS Code 設定的 Shell。選擇覆寫可從 VS Code 公開的路徑型 Shell 設定檔中選取。僅含來源的設定檔(如內建的 PowerShell 項目)無法在此列出。 <0>了解更多", + "overrideLabel": "為 Zoo Code 覆寫 Shell", + "configureButton": "在 VS Code 中選擇預設設定檔", + "noProfiles": "(在 terminal.integrated.profiles 中未找到路徑型設定檔)" } }, "advancedSettings": { From 75d4eee0e699f95ad86f555126fef5cacfc32f07 Mon Sep 17 00:00:00 2001 From: James Mtendamema <59908268+JamesRobert20@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:14:53 -0600 Subject: [PATCH 8/9] feat(zoo-gateway): auth callback and multi-profile token sync (#347) * feat(zoo-gateway): auth callback, profile token sync, and sign-out Co-authored-by: Cursor * test(zoo-gateway): cover auth callback profile sync and sign-out Add ClineProvider tests for handleZooCodeCallback, ensureZooGatewayProfileSeeded, and webviewMessageHandler zooCodeSignOut to satisfy codecov patch on PR #347. Co-authored-by: Cursor * fix(zoo-gateway): surface 401/402/403 errors with actionable toasts Clear the cached token on 401 and offer sign-in. On insufficient credits or budget limits, open the credits page. On account frozen/banned, open support. Errors still propagate to the task layer after the toast. Co-authored-by: Cursor * i18n(zoo-gateway): backfill zooAuth translations for 17 non-English locales Adds session_expired, out_of_credits, account_unavailable, budget_exceeded under zooAuth.errors and a new zooAuth.buttons block (sign_in, add_credits, contact_support) introduced by the gateway 401/402/403 UX so check-translations passes. Co-authored-by: Cursor * test(zoo-gateway): cover surfaceGatewayApiError UX branches for codecov patch Adds vscode + i18n mocks and asserts the 401/402/403/429 paths in surfaceGatewayApiError: token clear + sign-in URL on 401, add-credits URL on 402 and budget-coded 429, support URL on 403, no-op on 429 without a budget code or on errors without a status. Also verifies the helper still runs before completePrompt rewraps the upstream error. Co-authored-by: Cursor * fix(zoo-gateway): address PR review feedback on auth, seeding, and errors Co-authored-by: Cursor * test(zoo-gateway): cover stale baseUrl seeding path Co-authored-by: Cursor * fix(zoo-gateway): sign-out clears stale profile tokens, simplify model fetch Co-authored-by: Cursor * test(zoo-gateway): drop stale profile-scan test for requestRouterModels Co-authored-by: Cursor * fix(zoo-gateway): treat verify 5xx as transient, do not clear token The website's /api/extension/auth/verify route now returns 503 when the backend can't reach the database, instead of crashing. The extension previously treated any non-OK response from this endpoint as a definitively invalid token, which meant a transient backend hiccup would silently clear the user's session and force a fresh sign-in. verifyZooCodeToken now returns "unreachable" for 5xx responses (same classification as a network error), so initZooCodeAuth keeps the cached token in place and reports subscription status as "unknown" until the backend recovers. handleAuthCallback shows the could-not-verify message on 5xx so users see this is a temporary issue rather than a bad token. Co-authored-by: Cursor * refactor(zoo-gateway): split error classification from UX, extract callback fan-out Address review feedback on PR #347: - zoo-gateway.ts: split surfaceGatewayApiError into a pure classifyGatewayApiError (error -> action) plus a thin UX layer that switches on the action. The classifier is exported and covered by focused unit tests, so the status/code -> action mapping no longer needs the VS Code notification mocks to verify. - handleUri.ts: extract the per-instance token propagation loop into a propagateZooGatewayCallback helper, keeping the /auth-callback case focused on routing. Behaviour (sequential read-modify-write, per instance error isolation) is unchanged. Co-authored-by: Cursor * fix: isolate per-profile token cleanup on sign-out and assert state refresh Wrap per-profile work in the zoo-gateway sign-out cleanup loop in its own try/catch so one corrupted profile or failed write no longer aborts cleanup of the remaining profiles. Also assert postStateToWebview runs on the handleZooCodeCallback persistence-failure path. Co-authored-by: Cursor --------- Co-authored-by: James Mtendamema Co-authored-by: Cursor --- src/activate/__tests__/handleUri.spec.ts | 113 +++++++-- src/activate/handleUri.ts | 33 ++- .../providers/__tests__/zoo-gateway.spec.ts | 222 +++++++++++++++++- .../fetchers/__tests__/zoo-gateway.spec.ts | 1 + src/api/providers/fetchers/zoo-gateway.ts | 6 +- src/api/providers/zoo-gateway.ts | 186 ++++++++++++--- src/core/webview/ClineProvider.ts | 136 ++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 194 ++++++++++++++- .../__tests__/webviewMessageHandler.spec.ts | 95 +++++++- src/core/webview/webviewMessageHandler.ts | 61 +++++ src/i18n/locales/ca/common.json | 11 +- src/i18n/locales/de/common.json | 11 +- src/i18n/locales/en/common.json | 11 +- src/i18n/locales/es/common.json | 11 +- src/i18n/locales/fr/common.json | 11 +- src/i18n/locales/hi/common.json | 11 +- src/i18n/locales/id/common.json | 11 +- src/i18n/locales/it/common.json | 11 +- src/i18n/locales/ja/common.json | 11 +- src/i18n/locales/ko/common.json | 11 +- src/i18n/locales/nl/common.json | 11 +- src/i18n/locales/pl/common.json | 11 +- src/i18n/locales/pt-BR/common.json | 11 +- src/i18n/locales/ru/common.json | 11 +- src/i18n/locales/tr/common.json | 11 +- src/i18n/locales/vi/common.json | 11 +- src/i18n/locales/zh-CN/common.json | 11 +- src/i18n/locales/zh-TW/common.json | 11 +- src/services/__tests__/zoo-code-auth.test.ts | 56 ++++- src/services/zoo-code-auth.ts | 38 ++- .../welcome/WelcomeViewProvider.tsx | 12 +- 31 files changed, 1247 insertions(+), 104 deletions(-) diff --git a/src/activate/__tests__/handleUri.spec.ts b/src/activate/__tests__/handleUri.spec.ts index 187d9eeeba..b7aef0762b 100644 --- a/src/activate/__tests__/handleUri.spec.ts +++ b/src/activate/__tests__/handleUri.spec.ts @@ -6,25 +6,32 @@ vi.mock("vscode", () => ({ import * as vscode from "vscode" -const { mockGetVisibleInstance, mockHandleZooCodeAuthCallback, mockSetZooCodeUserInfo, mockVisibleProvider } = - vi.hoisted(() => { - const mockVisibleProvider = { - handleOpenRouterCallback: vi.fn(), - handleRequestyCallback: vi.fn(), - handleZooCodeCallback: vi.fn(), - } as any - - return { - mockGetVisibleInstance: vi.fn(() => mockVisibleProvider), - mockHandleZooCodeAuthCallback: vi.fn(), - mockSetZooCodeUserInfo: vi.fn(), - mockVisibleProvider, - } - }) +const { + mockGetVisibleInstance, + mockGetAllInstances, + mockHandleZooCodeAuthCallback, + mockSetZooCodeUserInfo, + mockVisibleProvider, +} = vi.hoisted(() => { + const mockVisibleProvider = { + handleOpenRouterCallback: vi.fn(), + handleRequestyCallback: vi.fn(), + handleZooCodeCallback: vi.fn(), + } as any + + return { + mockGetVisibleInstance: vi.fn(() => mockVisibleProvider), + mockGetAllInstances: vi.fn(() => [mockVisibleProvider]), + mockHandleZooCodeAuthCallback: vi.fn(), + mockSetZooCodeUserInfo: vi.fn(), + mockVisibleProvider, + } +}) vi.mock("../../core/webview/ClineProvider", () => ({ ClineProvider: { getVisibleInstance: mockGetVisibleInstance, + getAllInstances: mockGetAllInstances, }, })) @@ -39,6 +46,7 @@ describe("handleUri", () => { beforeEach(() => { vi.clearAllMocks() mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) + mockGetAllInstances.mockReturnValue([mockVisibleProvider]) }) it("ignores legacy cloud auth callback", async () => { @@ -54,8 +62,9 @@ describe("handleUri", () => { ) }) - it("stores callback user info even when no webview is visible", async () => { + it("stores callback user info even when no provider instances exist", async () => { mockGetVisibleInstance.mockReturnValue(null) + mockGetAllInstances.mockReturnValue([]) mockHandleZooCodeAuthCallback.mockResolvedValue(true) await handleUri({ @@ -69,6 +78,7 @@ describe("handleUri", () => { email: "jane@example.com", image: "https://example.com/avatar.png", }) + // No provider instances exist, so handleZooCodeCallback should not be called expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled() }) @@ -116,4 +126,75 @@ describe("handleUri", () => { expect(mockSetZooCodeUserInfo).not.toHaveBeenCalled() expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled() }) + + it("propagates the callback token to every ClineProvider instance, not just the visible one", async () => { + // Regression: prior to multi-instance fan-out, hidden providers (sidebar collapsed, + // secondary panels) never received the zooSessionToken, so their profile settings + // stayed unauthenticated until reload. + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + const hiddenProvider = { handleZooCodeCallback: vi.fn() } as any + const secondHidden = { handleZooCodeCallback: vi.fn() } as any + mockGetAllInstances.mockReturnValue([mockVisibleProvider, hiddenProvider, secondHidden]) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token", + } as any) + + expect(mockHandleZooCodeAuthCallback).toHaveBeenCalledWith("zoo_ext_test_token") + expect(mockSetZooCodeUserInfo).toHaveBeenCalled() + expect(mockVisibleProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + expect(hiddenProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + expect(secondHidden.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + }) + + it("serializes callbacks across instances to avoid concurrent profile-store writes", async () => { + // Regression: a previous implementation used Promise.all which fanned out concurrent + // read-modify-write operations on the same provider settings store. Verify the + // callbacks are invoked sequentially. + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + const order: string[] = [] + const makeProvider = (name: string) => + ({ + handleZooCodeCallback: vi.fn(async () => { + order.push(`${name}:start`) + // Yield to the event loop so a concurrent call would interleave. + await new Promise((resolve) => setTimeout(resolve, 0)) + order.push(`${name}:end`) + }), + }) as any + + const a = makeProvider("a") + const b = makeProvider("b") + mockGetAllInstances.mockReturnValue([a, b]) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token", + } as any) + + expect(order).toEqual(["a:start", "a:end", "b:start", "b:end"]) + }) + + it("continues fan-out when one instance fails to persist the callback token", async () => { + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + const failingProvider = { + handleZooCodeCallback: vi.fn(async () => { + throw new Error("profile store unavailable") + }), + } as any + const healthyProvider = { handleZooCodeCallback: vi.fn() } as any + mockGetAllInstances.mockReturnValue([failingProvider, healthyProvider]) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token", + } as any) + + expect(failingProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + expect(healthyProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + }) }) diff --git a/src/activate/handleUri.ts b/src/activate/handleUri.ts index 523d254bc3..cf0fc69175 100644 --- a/src/activate/handleUri.ts +++ b/src/activate/handleUri.ts @@ -4,6 +4,34 @@ import { getRouterUnavailableSignInMessage } from "../core/config/routerRemoval" import { ClineProvider } from "../core/webview/ClineProvider" import { handleAuthCallback as handleZooCodeAuthCallback, setZooCodeUserInfo } from "../services/zoo-code-auth" +/** + * Persist the Zoo Code session token to every active provider instance. + * + * The profile settings write (handleZooCodeCallback) must run on any active + * instance — not just the visible one — so the zoo-gateway zooSessionToken is + * persisted even when the sidebar/panel is hidden at callback time. + * + * Run sequentially (NOT Promise.all): each ClineProvider's handleZooCodeCallback + * does a read-modify-write on the same backing provider settings store + * (listConfig → getProfile → saveConfig / upsertProviderProfile). Fanning out + * concurrently across N instances can interleave reads/writes and clobber + * updates. Serialization is cheap (at most a handful of instances) and avoids + * the race. + */ +async function propagateZooGatewayCallback(token: string): Promise { + const allInstances = ClineProvider.getAllInstances() + for (const instance of allInstances) { + try { + await instance.handleZooCodeCallback(token) + } catch (error) { + console.error( + "Failed to persist Zoo Gateway token for a provider instance:", + error instanceof Error ? error.message : error, + ) + } + } +} + export const handleUri = async (uri: vscode.Uri) => { const path = uri.path const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B")) @@ -50,10 +78,7 @@ export const handleUri = async (uri: vscode.Uri) => { email, image, }) - // Refresh webview state if a panel is currently open - if (visibleProvider) { - await visibleProvider.handleZooCodeCallback(token) - } + await propagateZooGatewayCallback(token) } } break diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 76f29cc288..0d82b14f1a 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -1,14 +1,28 @@ // npx vitest run src/api/providers/__tests__/zoo-gateway.spec.ts -vitest.mock("vscode", () => ({})) +const { showErrorMessage, openExternal } = vitest.hoisted(() => ({ + showErrorMessage: vitest.fn(async () => undefined as string | undefined), + openExternal: vitest.fn(async () => true), +})) + +vitest.mock("vscode", () => ({ + window: { showErrorMessage }, + env: { openExternal, uriScheme: "vscode", appName: "VS Code" }, + Uri: { parse: (value: string) => ({ toString: () => value }) }, +})) + +vitest.mock("../../../i18n", () => ({ + t: (key: string) => key, +})) import OpenAI from "openai" import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" -import { ZooGatewayHandler } from "../zoo-gateway" +import { ZooGatewayHandler, classifyGatewayApiError } from "../zoo-gateway" import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" +import { clearZooCodeToken } from "../../../services/zoo-code-auth" vitest.mock("openai") vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) @@ -42,10 +56,22 @@ vitest.mock("../fetchers/modelCache", () => ({ getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) +const mockGetCachedZooCodeToken = vitest.hoisted(() => vitest.fn<() => string | undefined>(() => undefined)) +const mockSessionCleared = vitest.hoisted(() => ({ value: false })) + vitest.mock("../../../services/zoo-code-auth", () => ({ getZooCodeBaseUrl: vitest.fn(() => "https://www.zoocode.dev"), - getCachedZooCodeToken: vitest.fn(() => undefined), - clearZooCodeToken: vitest.fn(async () => undefined), + getCachedZooCodeToken: () => mockGetCachedZooCodeToken() ?? "", + resolveZooGatewaySessionToken: (profileToken?: string) => { + const cached = mockGetCachedZooCodeToken() + if (cached) return cached + if (mockSessionCleared.value) return undefined + return profileToken + }, + clearZooCodeToken: vitest.fn(async () => { + mockSessionCleared.value = true + mockGetCachedZooCodeToken.mockReturnValue(undefined) + }), })) vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({ @@ -77,10 +103,35 @@ describe("ZooGatewayHandler", () => { beforeEach(() => { vitest.clearAllMocks() + mockSessionCleared.value = false + mockGetCachedZooCodeToken.mockReturnValue(undefined) mockCreate.mockClear() + showErrorMessage.mockReset() + showErrorMessage.mockResolvedValue(undefined) + openExternal.mockReset() + openExternal.mockResolvedValue(true) mockOpenAIClient() }) + function makeApiError(status: number, options: { code?: string; message?: string } = {}) { + const err = new Error(options.message ?? `HTTP ${status}`) as Error & { + status: number + code?: string + } + err.status = status + if (options.code) err.code = options.code + return err + } + + async function drainCreateMessage(handler: ZooGatewayHandler) { + const stream = handler.createMessage("system", [{ role: "user", content: "hi" }]) + const out: unknown[] = [] + for await (const chunk of stream) { + out.push(chunk) + } + return out + } + describe("constructor", () => { it("allows construction without a session token (auth is enforced at request time)", () => { expect(() => new ZooGatewayHandler({})).not.toThrow() @@ -91,6 +142,21 @@ describe("ZooGatewayHandler", () => { ) }) + it("prefers the secret-storage cache over a persisted profile token", () => { + mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_cached_token") + + new ZooGatewayHandler({ + zooSessionToken: "zoo_ext_stale_profile_token", + zooGatewayModelId: mockOptions.zooGatewayModelId, + }) + + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "zoo_ext_cached_token", + }), + ) + }) + it("initializes OpenAI with Zoo enrichment headers and session token", () => { const handler = new ZooGatewayHandler({ ...mockOptions, @@ -140,6 +206,13 @@ describe("ZooGatewayHandler", () => { }) describe("createMessage", () => { + it("requires authentication at request time when no session token is available", async () => { + const handler = new ZooGatewayHandler({}) + await expect(drainCreateMessage(handler)).rejects.toThrow( + "Zoo Gateway requires authentication. Please sign in to Zoo Code first.", + ) + }) + beforeEach(() => { mockCreate.mockImplementation(async () => ({ [Symbol.asyncIterator]: async function* () { @@ -336,4 +409,145 @@ describe("ZooGatewayHandler", () => { await expect(handler.completePrompt("Test")).resolves.toBe("") }) }) + + describe("classifyGatewayApiError", () => { + it("returns sign_in on 401", () => { + expect(classifyGatewayApiError(makeApiError(401))).toEqual({ kind: "sign_in" }) + }) + + it("returns add_credits (not budget) on 402", () => { + expect(classifyGatewayApiError(makeApiError(402))).toEqual({ kind: "add_credits", budgetExceeded: false }) + }) + + it("returns add_credits with budgetExceeded on 429 budget codes", () => { + expect(classifyGatewayApiError(makeApiError(429, { code: "monthly_budget_exceeded" }))).toEqual({ + kind: "add_credits", + budgetExceeded: true, + }) + expect(classifyGatewayApiError(makeApiError(429, { code: "daily_budget_exceeded" }))).toEqual({ + kind: "add_credits", + budgetExceeded: true, + }) + }) + + it("returns none on 429 without a budget code", () => { + expect(classifyGatewayApiError(makeApiError(429, { code: "rate_limited" }))).toEqual({ kind: "none" }) + }) + + it("returns contact_support on 403", () => { + expect(classifyGatewayApiError(makeApiError(403))).toEqual({ kind: "contact_support" }) + }) + + it("returns none for errors without an HTTP status", () => { + expect(classifyGatewayApiError(new Error("network down"))).toEqual({ kind: "none" }) + }) + }) + + describe("surfaceGatewayApiError", () => { + it("clears the cached token and offers re-sign-in on 401", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(401) + }) + showErrorMessage.mockResolvedValueOnce("common:zooAuth.buttons.sign_in") + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(clearZooCodeToken).toHaveBeenCalledTimes(1) + expect(showErrorMessage).toHaveBeenCalledWith( + "common:zooAuth.errors.session_expired", + "common:zooAuth.buttons.sign_in", + ) + expect(openExternal).toHaveBeenCalledTimes(1) + }) + + it("does not open a URL on 401 when the user dismisses the prompt", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(401) + }) + showErrorMessage.mockResolvedValueOnce(undefined) + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(clearZooCodeToken).toHaveBeenCalledTimes(1) + expect(openExternal).not.toHaveBeenCalled() + }) + + it("prompts to add credits on 402", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(402) + }) + showErrorMessage.mockResolvedValueOnce("common:zooAuth.buttons.add_credits") + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(clearZooCodeToken).not.toHaveBeenCalled() + expect(showErrorMessage).toHaveBeenCalledWith( + "common:zooAuth.errors.out_of_credits", + "common:zooAuth.buttons.add_credits", + ) + expect(openExternal).toHaveBeenCalledTimes(1) + }) + + it("shows the budget message on 429 with a budget code", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(429, { code: "monthly_budget_exceeded" }) + }) + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(showErrorMessage).toHaveBeenCalledWith( + "common:zooAuth.errors.budget_exceeded", + "common:zooAuth.buttons.add_credits", + ) + }) + + it("does not surface a notification on 429 without a budget code", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(429, { code: "rate_limited" }) + }) + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(showErrorMessage).not.toHaveBeenCalled() + }) + + it("offers contact support on 403", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(403) + }) + showErrorMessage.mockResolvedValueOnce("common:zooAuth.buttons.contact_support") + + await expect(drainCreateMessage(handler)).rejects.toThrow() + expect(showErrorMessage).toHaveBeenCalledWith( + "common:zooAuth.errors.account_unavailable", + "common:zooAuth.buttons.contact_support", + ) + expect(openExternal).toHaveBeenCalledTimes(1) + }) + + it("ignores errors without an HTTP status", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw new Error("network down") + }) + + await expect(drainCreateMessage(handler)).rejects.toThrow("network down") + expect(showErrorMessage).not.toHaveBeenCalled() + expect(clearZooCodeToken).not.toHaveBeenCalled() + }) + + it("surfaces the gateway error then wraps the message in completePrompt", async () => { + const handler = new ZooGatewayHandler(mockOptions) + mockCreate.mockImplementation(() => { + throw makeApiError(402, { message: "out of credits" }) + }) + + await expect(handler.completePrompt("ping")).rejects.toThrow("Zoo Gateway completion error: out of credits") + expect(showErrorMessage).toHaveBeenCalledWith( + "common:zooAuth.errors.out_of_credits", + "common:zooAuth.buttons.add_credits", + ) + }) + }) }) diff --git a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts index f502b3a416..02f67b005c 100644 --- a/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/fetchers/__tests__/zoo-gateway.spec.ts @@ -8,6 +8,7 @@ vitest.mock("axios") vitest.mock("../../../../services/zoo-code-auth", () => ({ getCachedZooCodeToken: vitest.fn(() => ""), getZooCodeBaseUrl: vitest.fn(() => "https://example.test"), + resolveZooGatewaySessionToken: vitest.fn((profileToken?: string) => profileToken || undefined), })) const mockedAxios = axios as any diff --git a/src/api/providers/fetchers/zoo-gateway.ts b/src/api/providers/fetchers/zoo-gateway.ts index bd3cc1b170..cf6410b94f 100644 --- a/src/api/providers/fetchers/zoo-gateway.ts +++ b/src/api/providers/fetchers/zoo-gateway.ts @@ -3,7 +3,7 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" -import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../../services/zoo-code-auth" +import { getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../../services/zoo-code-auth" import { type VercelAiGatewayModel, @@ -24,9 +24,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise< const models: Record = {} const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` - // Build headers - Zoo Gateway requires authentication via the zoo_ext_ session token. - // Fall back to the secret-storage cache when the profile hasn't been seeded yet. - const sessionToken = options?.zooSessionToken || getCachedZooCodeToken() + const sessionToken = resolveZooGatewaySessionToken(options?.zooSessionToken) const headers: Record = {} if (sessionToken) { headers["Authorization"] = `Bearer ${sessionToken}` diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts index bded670814..5c0df04887 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" @@ -9,8 +10,9 @@ import { } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" -import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../../services/zoo-code-auth" +import { clearZooCodeToken, getZooCodeBaseUrl, resolveZooGatewaySessionToken } from "../../services/zoo-code-auth" import { Package } from "../../shared/package" +import { t } from "../../i18n" import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -19,6 +21,103 @@ import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { RouterProvider } from "./router-provider" +function getApiErrorStatus(error: unknown): number | undefined { + if (typeof error === "object" && error !== null && "status" in error) { + const status = (error as { status: unknown }).status + if (typeof status === "number") return status + } + return undefined +} + +function getApiErrorCode(error: unknown): string | undefined { + const err = error as { code?: unknown; error?: { code?: unknown } } | null + if (!err) return undefined + if (typeof err.code === "string") return err.code + if (typeof err.error?.code === "string") return err.error.code + return undefined +} + +function buildZooCodeSignInUrl(): string { + const callbackUri = encodeURIComponent( + `${vscode.env.uriScheme}://${Package.publisher}.${Package.name}/auth-callback`, + ) + const device = encodeURIComponent(vscode.env.appName || "VS Code") + const editor = encodeURIComponent("VS Code") + return `${getZooCodeBaseUrl()}/dashboard/connect?device=${device}&editor=${editor}&version=${Package.version}&callback_uri=${callbackUri}` +} + +type ZooGatewayApiErrorAction = + | { kind: "sign_in" } + | { kind: "add_credits"; budgetExceeded: boolean } + | { kind: "contact_support" } + | { kind: "none" } + +// Pure mapping from an API error to the UX action it warrants. No side effects, +// so this is trivial to unit test independently of the VS Code notification flow. +// Exported for unit tests. +export function classifyGatewayApiError(error: unknown): ZooGatewayApiErrorAction { + const status = getApiErrorStatus(error) + if (status === undefined) return { kind: "none" } + const code = getApiErrorCode(error) + + if (status === 401) { + return { kind: "sign_in" } + } + + const isBudgetExceeded = status === 429 && (code === "monthly_budget_exceeded" || code === "daily_budget_exceeded") + if (status === 402 || isBudgetExceeded) { + return { kind: "add_credits", budgetExceeded: isBudgetExceeded } + } + + if (status === 403) { + return { kind: "contact_support" } + } + + return { kind: "none" } +} + +// Caller must always rethrow — this only surfaces UX, never swallows. +async function surfaceGatewayApiError(error: unknown): Promise { + const action = classifyGatewayApiError(error) + + switch (action.kind) { + case "sign_in": { + // Wipe before sign-in so the callback rebinds against an empty slot. + await clearZooCodeToken() + const clicked = await vscode.window.showErrorMessage( + t("common:zooAuth.errors.session_expired"), + t("common:zooAuth.buttons.sign_in"), + ) + if (clicked) { + void vscode.env.openExternal(vscode.Uri.parse(buildZooCodeSignInUrl())) + } + return + } + case "add_credits": { + const message = action.budgetExceeded + ? t("common:zooAuth.errors.budget_exceeded") + : t("common:zooAuth.errors.out_of_credits") + const clicked = await vscode.window.showErrorMessage(message, t("common:zooAuth.buttons.add_credits")) + if (clicked) { + void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/dashboard/credits`)) + } + return + } + case "contact_support": { + const clicked = await vscode.window.showErrorMessage( + t("common:zooAuth.errors.account_unavailable"), + t("common:zooAuth.buttons.contact_support"), + ) + if (clicked) { + void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/support`)) + } + return + } + default: + return + } +} + // Extend OpenAI's CompletionUsage to include Zoo Gateway specific fields (same as Vercel AI Gateway) interface ZooGatewayUsage extends OpenAI.CompletionUsage { cache_creation_input_tokens?: number @@ -31,9 +130,7 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio constructor(options: ApiHandlerOptions) { const baseURL = options.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` - // Prefer the secret-storage cache so a 401 clear takes effect immediately; fall back - // to the profile-persisted token when the user is signed in but seeding hasn't run yet. - const sessionToken = getCachedZooCodeToken() || options.zooSessionToken + const sessionToken = resolveZooGatewaySessionToken(options.zooSessionToken) // Merge Zoo-specific enrichment headers into openAiHeaders so they flow through // the parent's single OpenAI client. We avoid reassigning `this.client` (which @@ -58,8 +155,7 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio } private ensureAuthenticated(): void { - const sessionToken = getCachedZooCodeToken() || this.options.zooSessionToken - if (!sessionToken) { + if (!resolveZooGatewaySessionToken(this.options.zooSessionToken)) { throw new Error(ZOO_GATEWAY_AUTH_ERROR) } } @@ -107,43 +203,55 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio parallel_tool_calls: metadata?.parallelToolCalls ?? true, } - const completion = await this.client.chat.completions.create(body, { - headers: requestHeaders, - }) + try { + const completion = await this.client.chat.completions.create(body, { + headers: requestHeaders, + }) + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } - for await (const chunk of completion) { - const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { - type: "text", - text: delta.content, + // Emit raw tool call chunks - NativeToolCallParser handles state management + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } } - } - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { + if (chunk.usage) { + const usage = chunk.usage as ZooGatewayUsage yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, + type: "usage", + inputTokens: usage.prompt_tokens || 0, + outputTokens: usage.completion_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens || undefined, + cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined, + totalCost: usage.cost ?? 0, } } } - - if (chunk.usage) { - const usage = chunk.usage as ZooGatewayUsage - yield { - type: "usage", - inputTokens: usage.prompt_tokens || 0, - outputTokens: usage.completion_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined, - totalCost: usage.cost ?? 0, - } + } catch (error) { + try { + await surfaceGatewayApiError(error) + } catch (surfaceError) { + console.error( + "Failed to surface Zoo Gateway error:", + surfaceError instanceof Error ? surfaceError.message : surfaceError, + ) } + throw error } } @@ -168,6 +276,14 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio const response = await this.client.chat.completions.create(requestOptions) return response.choices[0]?.message.content || "" } catch (error) { + try { + await surfaceGatewayApiError(error) + } catch (surfaceError) { + console.error( + "Failed to surface Zoo Gateway error:", + surfaceError instanceof Error ? surfaceError.message : surfaceError, + ) + } if (error instanceof Error) { throw new Error(`Zoo Gateway completion error: ${error.message}`) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 38ad631271..a34b0fcbee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -655,6 +655,10 @@ export class ClineProvider return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true) } + public static getAllInstances(): ClineProvider[] { + return Array.from(this.activeInstances) + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() @@ -888,6 +892,64 @@ export class ClineProvider if (!currentTask || currentTask.abandoned || currentTask.abort) { await this.removeClineFromStack() } + + // Ensure zoo-gateway profile is seeded for users who signed in before this feature existed. + // Without this, users with a valid cached token but no zoo-gateway profile would need to + // re-authenticate to use Zoo Gateway. Fire-and-forget to avoid blocking webview init. + void this.ensureZooGatewayProfileSeeded().catch((err) => { + this.log(`[ensureZooGatewayProfileSeeded] Error: ${err instanceof Error ? err.message : String(err)}`) + }) + } + + /** + * Seeds the zoo-gateway provider profile for users who have a cached auth token + * but no profile (e.g., users who signed in before Zoo Gateway was added), or + * who have an empty/imported profile without a token. + * Called once per webview init; handleZooCodeCallback is idempotent so repeated calls are safe. + */ + private async ensureZooGatewayProfileSeeded(): Promise { + const { getCachedZooCodeToken, getZooCodeBaseUrl } = await import("../../services/zoo-code-auth") + const token = getCachedZooCodeToken() + if (!token) return + const expectedGatewayBaseUrl = `${getZooCodeBaseUrl()}/api/gateway/v1` + + // Check ALL zoo-gateway profiles — only skip seeding if every profile has the current token. + // 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") + + if (zooGatewayProfiles.length === 0) { + this.log("[ensureZooGatewayProfileSeeded] No zoo-gateway profile found, creating one") + } else { + let allUpToDate = true + + for (const entry of zooGatewayProfiles) { + try { + const fullProfile = await this.providerSettingsManager.getProfile({ name: entry.name }) + if ( + fullProfile.zooSessionToken !== token || + fullProfile.zooGatewayBaseUrl !== expectedGatewayBaseUrl + ) { + allUpToDate = false + this.log("[ensureZooGatewayProfileSeeded] Existing zoo-gateway profile is stale, updating") + break + } + } catch { + allUpToDate = false + this.log("[ensureZooGatewayProfileSeeded] Failed to read existing profile, will re-seed") + break + } + } + + if (allUpToDate) { + // All profiles have the current token — nothing to do + return + } + } + + // User has token but either no profile, some profiles without token, or stale tokens — seed all + await this.handleZooCodeCallback(token) } public async createTaskWithHistoryItem( @@ -1678,12 +1740,80 @@ export class ClineProvider await this.upsertProviderProfile(currentApiConfigName, newConfiguration) } - // Zoo Code Auth (for observability telemetry) + // Zoo Code Auth - async handleZooCodeCallback(_token: string) { + async handleZooCodeCallback(token: string) { // Auth mutation (token storage, subscription check, success toast) was already // performed by handleAuthCallback() in handleUri.ts before this method was called. - // This method only needs to refresh the webview state to reflect the new auth status. + // Save the zoo-gateway provider profile with the session token so that + // ZooGatewayHandler can authenticate without any manual user input. + // + // activate: true ONLY if Zoo Gateway is already the active profile — this pushes + // the new token to the in-memory handler so the current task picks it up immediately. + // Otherwise activate: false — do NOT switch providers mid-conversation. The user + // must explicitly select Zoo Gateway in settings if they want to use it. + try { + const { apiConfiguration } = await this.getState() + const currentSettings = this.contextProxy.getProviderSettings() + const currentApiConfigName = this.contextProxy.getValues().currentApiConfigName + + // Derive the gateway base URL from ZOO_CODE_BASE_URL so that non-prod environments + // (staging, local dev) route completions to the correct backend instead of always + // hard-coding production. An already-set value in the profile is NOT preserved here — + // it must always align with the auth server the user just authenticated against. + const { getZooCodeBaseUrl } = await import("../../services/zoo-code-auth") + const derivedGatewayBaseUrl = `${getZooCodeBaseUrl()}/api/gateway/v1` + + // 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" + + // 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") + + if (zooProfiles.length === 0) { + // No existing zoo-gateway profile — create the canonical default. + const newConfiguration: ProviderSettings = { + apiProvider: "zoo-gateway", + zooSessionToken: token, + zooGatewayModelId: apiConfiguration.zooGatewayModelId, + zooGatewayBaseUrl: derivedGatewayBaseUrl, + } + // Activate only if zoo-gateway was the active provider (shouldn't happen if + // no profiles exist, but defensive). + await this.upsertProviderProfile("Zoo Gateway", newConfiguration, isZooGatewayActive) + } else { + // Update every existing zoo-gateway profile with the new token and the + // derived base URL so that environment-specific routing stays consistent. + for (const entry of zooProfiles) { + const isActiveProfile = isZooGatewayActive && entry.name === currentApiConfigName + const existing = await this.providerSettingsManager.getProfile({ name: entry.name }) + const updated: ProviderSettings = { + ...existing, + zooSessionToken: token, + zooGatewayBaseUrl: derivedGatewayBaseUrl, + } + if (isActiveProfile) { + // Use upsertProviderProfile with activate: true so the in-memory handler + // picks up the new token immediately for the current task. + await this.upsertProviderProfile(entry.name, updated, true) + } else { + // Non-active profiles just need the token saved to disk. + await this.providerSettingsManager.saveConfig(entry.name, updated) + } + } + } + } catch (error) { + this.log( + `[handleZooCodeCallback] Failed to save zoo-gateway profile: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } await this.postStateToWebview() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index fd63f2a978..015981c0df 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -231,6 +231,14 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModelsFromCache: vi.fn().mockReturnValue(undefined), })) +vi.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vi.fn(), + handleAuthCallback: vi.fn(), + setZooCodeUserInfo: vi.fn(), + disconnectZooCode: vi.fn(), +})) + vi.mock("../../../shared/modes", () => ({ modes: [ { @@ -2488,11 +2496,11 @@ describe("ClineProvider - Router Models", () => { openrouter: mockModels, requesty: mockModels, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, litellm: mockModels, ollama: {}, lmstudio: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -2525,6 +2533,7 @@ describe("ClineProvider - Router Models", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail .mockResolvedValueOnce(mockModels) // unbound success .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success + .mockResolvedValueOnce(mockModels) // zoo-gateway success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail await messageHandler({ type: "requestRouterModels" }) @@ -2536,11 +2545,11 @@ describe("ClineProvider - Router Models", () => { openrouter: mockModels, requesty: {}, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, ollama: {}, lmstudio: {}, litellm: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -2633,11 +2642,11 @@ describe("ClineProvider - Router Models", () => { openrouter: mockModels, requesty: mockModels, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, litellm: {}, ollama: {}, lmstudio: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -3678,4 +3687,177 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.mocked(fsUtils.fileExistsAtPath).mockRestore() }) }) + + describe("Zoo Code auth profile sync", () => { + beforeEach(async () => { + const { getCachedZooCodeToken } = await import("../../../services/zoo-code-auth") + vi.mocked(getCachedZooCodeToken).mockReturnValue("") + }) + + describe("handleZooCodeCallback", () => { + it("creates a Zoo Gateway profile when none exists", async () => { + vi.spyOn(provider, "getState").mockResolvedValue({ + apiConfiguration: { zooGatewayModelId: "anthropic/claude-sonnet-4" }, + } as any) + vi.spyOn(provider.contextProxy, "getProviderSettings").mockReturnValue({ + apiProvider: "anthropic", + } as any) + vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({ + currentApiConfigName: "Anthropic", + } as any) + const upsertSpy = vi.spyOn(provider, "upsertProviderProfile").mockResolvedValue("profile-id") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([]), + } + + await provider.handleZooCodeCallback("zoo_ext_token") + + expect(upsertSpy).toHaveBeenCalledWith( + "Zoo Gateway", + expect.objectContaining({ + apiProvider: "zoo-gateway", + zooSessionToken: "zoo_ext_token", + zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", + }), + false, + ) + }) + + it("updates every zoo-gateway profile and activates only the active one", async () => { + vi.spyOn(provider, "getState").mockResolvedValue({ + apiConfiguration: { zooGatewayModelId: "anthropic/claude-sonnet-4" }, + } as any) + vi.spyOn(provider.contextProxy, "getProviderSettings").mockReturnValue({ + apiProvider: "zoo-gateway", + } as any) + vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({ + currentApiConfigName: "Zoo Gateway", + } as any) + const upsertSpy = vi.spyOn(provider, "upsertProviderProfile").mockResolvedValue("profile-id") + const saveConfig = vi.fn().mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, + { name: "Backup Zoo", apiProvider: "zoo-gateway" }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "old-token", + zooGatewayBaseUrl: "https://old.example/api/gateway/v1", + }) + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "old-token", + }), + saveConfig, + } + + await provider.handleZooCodeCallback("new-token") + + expect(upsertSpy).toHaveBeenCalledWith( + "Zoo Gateway", + expect.objectContaining({ + zooSessionToken: "new-token", + zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", + }), + true, + ) + expect(saveConfig).toHaveBeenCalledWith( + "Backup Zoo", + expect.objectContaining({ + zooSessionToken: "new-token", + zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", + }), + ) + }) + + it("logs and posts state when profile persistence fails", async () => { + vi.spyOn(provider, "getState").mockRejectedValue(new Error("state unavailable")) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([]), + } + + await provider.handleZooCodeCallback("zoo_ext_token") + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[handleZooCodeCallback] Failed to save zoo-gateway profile"), + ) + // State must still be refreshed even when profile persistence fails. + expect(provider.postStateToWebview).toHaveBeenCalled() + }) + }) + + describe("ensureZooGatewayProfileSeeded", () => { + it("does nothing when no cached auth token exists", async () => { + const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) + + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn(), + } + + await (provider as any).ensureZooGatewayProfileSeeded() + + expect(handleSpy).not.toHaveBeenCalled() + }) + + it("skips seeding when every zoo-gateway profile already has the current token and base URL", async () => { + const { getCachedZooCodeToken } = await import("../../../services/zoo-code-auth") + vi.mocked(getCachedZooCodeToken).mockReturnValue("current-token") + const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) + + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + getProfile: vi.fn().mockResolvedValue({ + zooSessionToken: "current-token", + zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", + }), + } + + await (provider as any).ensureZooGatewayProfileSeeded() + + expect(handleSpy).not.toHaveBeenCalled() + }) + + it("re-seeds when any zoo-gateway profile has a stale or missing token", async () => { + const { getCachedZooCodeToken } = await import("../../../services/zoo-code-auth") + vi.mocked(getCachedZooCodeToken).mockReturnValue("fresh-token") + const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) + + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + getProfile: vi.fn().mockResolvedValue({ + zooSessionToken: "stale-token", + zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", + }), + } + + await (provider as any).ensureZooGatewayProfileSeeded() + + expect(handleSpy).toHaveBeenCalledWith("fresh-token") + }) + + it("re-seeds when any zoo-gateway profile has a stale base URL", async () => { + const { getCachedZooCodeToken } = await import("../../../services/zoo-code-auth") + vi.mocked(getCachedZooCodeToken).mockReturnValue("current-token") + const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) + + ;(provider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + getProfile: vi.fn().mockResolvedValue({ + zooSessionToken: "current-token", + zooGatewayBaseUrl: "https://staging.zoocode.dev/api/gateway/v1", + }), + } + + await (provider as any).ensureZooGatewayProfileSeeded() + + expect(handleSpy).toHaveBeenCalledWith("current-token") + }) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 224e9dece6..bb2c9f34ec 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -4,6 +4,9 @@ import type { Mock } from "vitest" // Mock dependencies - must come before imports vi.mock("../../../api/providers/fetchers/modelCache") +vi.mock("../../../services/zoo-code-auth", () => ({ + disconnectZooCode: vi.fn().mockResolvedValue(undefined), +})) vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ getLMStudioModels: vi.fn(), })) @@ -371,11 +374,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { openrouter: mockModels, requesty: mockModels, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, litellm: mockModels, ollama: {}, lmstudio: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -459,11 +462,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { openrouter: mockModels, requesty: mockModels, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, litellm: {}, ollama: {}, lmstudio: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -488,6 +491,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty .mockResolvedValueOnce(mockModels) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway + .mockResolvedValueOnce(mockModels) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -516,11 +520,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { openrouter: mockModels, requesty: {}, unbound: mockModels, + "vercel-ai-gateway": mockModels, + "zoo-gateway": mockModels, litellm: {}, ollama: {}, lmstudio: {}, - "vercel-ai-gateway": mockModels, - "zoo-gateway": {}, poe: {}, deepseek: {}, "opencode-go": {}, @@ -536,6 +540,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty .mockRejectedValueOnce(new Error("Unbound error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway + .mockRejectedValueOnce(new Error("Zoo Gateway error")) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -1224,3 +1229,81 @@ describe("webviewMessageHandler - downloadErrorDiagnostics", () => { expect(generateErrorDiagnostics).not.toHaveBeenCalled() }) }) + +describe("zooCodeSignOut", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + 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) + + ;(mockClineProvider as any).contextProxy = { + ...mockClineProvider.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + } + ;(mockClineProvider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: "zoo-gateway" }, + { name: "Backup Zoo", apiProvider: "zoo-gateway" }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "token-active", + zooGatewayModelId: "anthropic/claude-sonnet-4", + }) + .mockResolvedValueOnce({ + apiProvider: "zoo-gateway", + zooSessionToken: "token-backup", + }), + saveConfig, + } + ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + + await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + + expect(disconnectZooCode).toHaveBeenCalled() + expect(upsertProviderProfile).toHaveBeenCalledWith( + "Zoo Gateway", + expect.not.objectContaining({ zooSessionToken: expect.anything() }), + true, + ) + expect(saveConfig).toHaveBeenCalledWith( + "Backup Zoo", + expect.not.objectContaining({ zooSessionToken: expect.anything() }), + ) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("still clears the in-memory handler when the active profile token is already empty on disk", async () => { + const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) + + ;(mockClineProvider as any).contextProxy = { + ...mockClineProvider.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "zoo-gateway" }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + } + ;(mockClineProvider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "Zoo Gateway", apiProvider: "zoo-gateway" }]), + getProfile: vi.fn().mockResolvedValue({ + apiProvider: "zoo-gateway", + zooGatewayModelId: "anthropic/claude-sonnet-4", + }), + saveConfig: vi.fn(), + } + ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + + await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + + expect(upsertProviderProfile).toHaveBeenCalledWith( + "Zoo Gateway", + expect.not.objectContaining({ zooSessionToken: expect.anything() }), + true, + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2ee7373119..097ecb6578 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -977,6 +977,13 @@ export const webviewMessageHandler = async ( }, }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { + key: "zoo-gateway", + options: { + provider: "zoo-gateway", + baseUrl: apiConfiguration.zooGatewayBaseUrl, + }, + }, ] // LiteLLM is conditional on baseUrl+apiKey @@ -2477,6 +2484,60 @@ export const webviewMessageHandler = async ( try { const { disconnectZooCode } = await import("../../services/zoo-code-auth") await disconnectZooCode() + + // Clear zooSessionToken from ALL provider profiles with apiProvider === "zoo-gateway". + // Profiles are user-renameable, so we cannot rely on a hardcoded name like "Zoo Gateway". + // We must scan all profiles and clear tokens from any that use the zoo-gateway provider. + try { + 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 currentApiConfigName = provider.contextProxy.getValues().currentApiConfigName + + for (const entry of allProfiles) { + if (entry.apiProvider !== "zoo-gateway") { + continue + } + + // Isolate per-profile failures: a corrupted profile or a failed write + // for one entry must not abort cleanup of the remaining profiles, + // otherwise sign-out would leave later profiles with a stale token. + try { + const profile = await provider.providerSettingsManager.getProfile({ name: entry.name }) + const { zooSessionToken: _removed, ...cleanedProfile } = profile + + // If this is the currently active profile, ALWAYS push to the in-memory + // handler — even when the persisted profile has already been cleared — + // because currentSettings (and therefore the live API handler) may still + // carry a stale token from before sign-out. Persisted-only profiles get + // rewritten only when they previously had a token to avoid no-op disk writes. + const isThisProfileActive = isZooGatewayActive && currentApiConfigName === entry.name + + if (isThisProfileActive) { + await provider.upsertProviderProfile(entry.name, cleanedProfile, true) + provider.log( + `[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile and updated in-memory handler`, + ) + } else if (profile.zooSessionToken) { + await provider.providerSettingsManager.saveConfig(entry.name, cleanedProfile) + provider.log(`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile`) + } + } catch (profileError) { + // Log but continue to the next profile so one failure doesn't + // leave other profiles holding a stale token. + provider.log( + `[zooCodeSignOut] Failed to clear profile token for "${entry.name}": ${profileError instanceof Error ? profileError.message : String(profileError)}`, + ) + } + } + } catch (profileError) { + // listConfig itself failed — nothing to iterate. + provider.log( + `[zooCodeSignOut] Failed to list profiles for token cleanup: ${profileError instanceof Error ? profileError.message : String(profileError)}`, + ) + } + await provider.postStateToWebview() } catch (error) { provider.log( diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index a8c0300337..0a1c99f1f8 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: S'ha rebut un token d'autenticació no vàlid.", "token_verification_failed": "Zoo Code: Ha fallat la verificació del token.", "invalid_token": "Zoo Code: Token no vàlid.", - "could_not_verify_token": "Zoo Code: No s'ha pogut verificar el token." + "could_not_verify_token": "Zoo Code: No s'ha pogut verificar el token.", + "session_expired": "Sessió de Zoo Code caducada. Torna a iniciar sessió per continuar utilitzant Zoo Gateway.", + "out_of_credits": "Zoo Gateway: crèdits insuficients. Afegeix crèdits per continuar.", + "account_unavailable": "Zoo Gateway: el teu compte no està disponible. Contacta amb el suport.", + "budget_exceeded": "Zoo Gateway: pressupost d'ús assolit. Espera que es restableixi o afegeix crèdits." }, "info": { "connected": "Zoo Code: Connectat correctament! Ara podeu utilitzar Zoo Code com a proveïdor d'IA.", "disconnected": "Zoo Code: Desconnectat correctament." + }, + "buttons": { + "sign_in": "Inicia sessió", + "add_credits": "Afegeix crèdits", + "contact_support": "Contacta amb el suport" } }, "codeActions": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 10961de9af..02f27925a5 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -256,11 +256,20 @@ "invalid_token_received": "Zoo Code: Ungültiger Authentifizierungstoken empfangen.", "token_verification_failed": "Zoo Code: Token-Überprüfung fehlgeschlagen.", "invalid_token": "Zoo Code: Ungültiger Token.", - "could_not_verify_token": "Zoo Code: Token konnte nicht verifiziert werden." + "could_not_verify_token": "Zoo Code: Token konnte nicht verifiziert werden.", + "session_expired": "Zoo Code-Sitzung abgelaufen. Bitte melde dich erneut an, um Zoo Gateway weiter zu nutzen.", + "out_of_credits": "Zoo Gateway: Unzureichendes Guthaben. Füge Guthaben hinzu, um fortzufahren.", + "account_unavailable": "Zoo Gateway: Dein Konto ist derzeit nicht verfügbar. Bitte kontaktiere den Support.", + "budget_exceeded": "Zoo Gateway: Nutzungsbudget erreicht. Warte auf das Zurücksetzen oder lade auf." }, "info": { "connected": "Zoo Code: Erfolgreich verbunden! Du kannst Zoo Code jetzt als KI-Anbieter verwenden.", "disconnected": "Zoo Code: Erfolgreich getrennt." + }, + "buttons": { + "sign_in": "Anmelden", + "add_credits": "Guthaben hinzufügen", + "contact_support": "Support kontaktieren" } }, "codeActions": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 785e08e83b..5abed998a5 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -253,11 +253,20 @@ "invalid_token_received": "Zoo Code: Invalid authentication token received.", "token_verification_failed": "Zoo Code: Token verification failed.", "invalid_token": "Zoo Code: Invalid token.", - "could_not_verify_token": "Zoo Code: Could not verify token." + "could_not_verify_token": "Zoo Code: Could not verify token.", + "session_expired": "Zoo Code session expired. Please sign in again to continue using Zoo Gateway.", + "out_of_credits": "Zoo Gateway: insufficient credits. Add credits to continue.", + "account_unavailable": "Zoo Gateway: your account is currently unavailable. Please contact support.", + "budget_exceeded": "Zoo Gateway: usage budget reached. Wait for the budget to reset or top up." }, "info": { "connected": "Zoo Code: Successfully connected! You can now use Zoo Code as your AI provider.", "disconnected": "Zoo Code: Disconnected successfully." + }, + "buttons": { + "sign_in": "Sign In", + "add_credits": "Add Credits", + "contact_support": "Contact Support" } }, "codeActions": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index afe7e29a9e..3c5d6ea754 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -256,11 +256,20 @@ "invalid_token_received": "Zoo Code: Se recibió un token de autenticación inválido.", "token_verification_failed": "Zoo Code: Error al verificar el token.", "invalid_token": "Zoo Code: Token inválido.", - "could_not_verify_token": "Zoo Code: No se pudo verificar el token." + "could_not_verify_token": "Zoo Code: No se pudo verificar el token.", + "session_expired": "Sesión de Zoo Code caducada. Inicia sesión de nuevo para seguir usando Zoo Gateway.", + "out_of_credits": "Zoo Gateway: créditos insuficientes. Añade créditos para continuar.", + "account_unavailable": "Zoo Gateway: tu cuenta no está disponible. Contacta con soporte.", + "budget_exceeded": "Zoo Gateway: presupuesto de uso alcanzado. Espera al restablecimiento o recarga." }, "info": { "connected": "Zoo Code: ¡Conectado correctamente! Ahora puedes usar Zoo Code como proveedor de IA.", "disconnected": "Zoo Code: Desconectado correctamente." + }, + "buttons": { + "sign_in": "Iniciar sesión", + "add_credits": "Añadir créditos", + "contact_support": "Contactar con soporte" } }, "codeActions": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 08c3a4ce82..7f9e466f04 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Jeton d'authentification invalide reçu.", "token_verification_failed": "Zoo Code: La vérification du jeton a échoué.", "invalid_token": "Zoo Code: Jeton invalide.", - "could_not_verify_token": "Zoo Code: Impossible de vérifier le jeton." + "could_not_verify_token": "Zoo Code: Impossible de vérifier le jeton.", + "session_expired": "Session Zoo Code expirée. Reconnectez-vous pour continuer à utiliser Zoo Gateway.", + "out_of_credits": "Zoo Gateway : crédits insuffisants. Ajoutez des crédits pour continuer.", + "account_unavailable": "Zoo Gateway : votre compte n'est pas disponible actuellement. Contactez le support.", + "budget_exceeded": "Zoo Gateway : budget d'utilisation atteint. Attendez la réinitialisation ou rechargez." }, "info": { "connected": "Zoo Code: Connecté avec succès ! Vous pouvez maintenant utiliser Zoo Code comme fournisseur d'IA.", "disconnected": "Zoo Code: Déconnecté avec succès." + }, + "buttons": { + "sign_in": "Se connecter", + "add_credits": "Ajouter des crédits", + "contact_support": "Contacter le support" } }, "codeActions": { diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 34a1114ef2..3c567dac08 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: अमान्य प्रमाणीकरण token प्राप्त हुआ।", "token_verification_failed": "Zoo Code: Token सत्यापन विफल रहा।", "invalid_token": "Zoo Code: अमान्य token।", - "could_not_verify_token": "Zoo Code: Token सत्यापित नहीं किया जा सका।" + "could_not_verify_token": "Zoo Code: Token सत्यापित नहीं किया जा सका।", + "session_expired": "Zoo Code सत्र समाप्त हो गया है। Zoo Gateway का उपयोग जारी रखने के लिए कृपया फिर से साइन इन करें।", + "out_of_credits": "Zoo Gateway: अपर्याप्त क्रेडिट। जारी रखने के लिए क्रेडिट जोड़ें।", + "account_unavailable": "Zoo Gateway: आपका खाता वर्तमान में उपलब्ध नहीं है। कृपया सहायता से संपर्क करें।", + "budget_exceeded": "Zoo Gateway: उपयोग बजट पूरा हो गया है। बजट रीसेट होने की प्रतीक्षा करें या रिचार्ज करें।" }, "info": { "connected": "Zoo Code: सफलतापूर्वक कनेक्ट हो गया! अब तुम Zoo Code को अपने AI प्रदाता के रूप में उपयोग कर सकते हो।", "disconnected": "Zoo Code: सफलतापूर्वक डिस्कनेक्ट हो गया।" + }, + "buttons": { + "sign_in": "साइन इन करें", + "add_credits": "क्रेडिट जोड़ें", + "contact_support": "सहायता से संपर्क करें" } }, "codeActions": { diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index faa610c89b..7208ea9507 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Token autentikasi yang diterima tidak valid.", "token_verification_failed": "Zoo Code: Verifikasi token gagal.", "invalid_token": "Zoo Code: Token tidak valid.", - "could_not_verify_token": "Zoo Code: Tidak dapat memverifikasi token." + "could_not_verify_token": "Zoo Code: Tidak dapat memverifikasi token.", + "session_expired": "Sesi Zoo Code kedaluwarsa. Silakan masuk lagi untuk melanjutkan menggunakan Zoo Gateway.", + "out_of_credits": "Zoo Gateway: kredit tidak cukup. Tambahkan kredit untuk melanjutkan.", + "account_unavailable": "Zoo Gateway: akun Anda sedang tidak tersedia. Silakan hubungi dukungan.", + "budget_exceeded": "Zoo Gateway: anggaran penggunaan tercapai. Tunggu reset anggaran atau isi ulang." }, "info": { "connected": "Zoo Code: Berhasil terhubung! Kamu sekarang bisa menggunakan Zoo Code sebagai penyedia AI.", "disconnected": "Zoo Code: Berhasil terputus." + }, + "buttons": { + "sign_in": "Masuk", + "add_credits": "Tambah Kredit", + "contact_support": "Hubungi Dukungan" } }, "codeActions": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index e3961e5901..a69a96092c 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Ricevuto un token di autenticazione non valido.", "token_verification_failed": "Zoo Code: Verifica del token non riuscita.", "invalid_token": "Zoo Code: Token non valido.", - "could_not_verify_token": "Zoo Code: Impossibile verificare il token." + "could_not_verify_token": "Zoo Code: Impossibile verificare il token.", + "session_expired": "Sessione Zoo Code scaduta. Accedi di nuovo per continuare a usare Zoo Gateway.", + "out_of_credits": "Zoo Gateway: crediti insufficienti. Aggiungi crediti per continuare.", + "account_unavailable": "Zoo Gateway: il tuo account non è attualmente disponibile. Contatta l'assistenza.", + "budget_exceeded": "Zoo Gateway: budget di utilizzo raggiunto. Attendi il reset o ricarica." }, "info": { "connected": "Zoo Code: Connesso con successo! Ora puoi usare Zoo Code come fornitore AI.", "disconnected": "Zoo Code: Disconnesso con successo." + }, + "buttons": { + "sign_in": "Accedi", + "add_credits": "Aggiungi crediti", + "contact_support": "Contatta l'assistenza" } }, "codeActions": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a1804e4b8c..bea4db707c 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: 無効な認証 token を受信しました。", "token_verification_failed": "Zoo Code: Token の検証に失敗しました。", "invalid_token": "Zoo Code: 無効な token です。", - "could_not_verify_token": "Zoo Code: Token を検証できませんでした。" + "could_not_verify_token": "Zoo Code: Token を検証できませんでした。", + "session_expired": "Zoo Codeのセッションの有効期限が切れました。Zoo Gatewayを引き続き使用するには、再度サインインしてください。", + "out_of_credits": "Zoo Gateway: クレジットが不足しています。続行するにはクレジットを追加してください。", + "account_unavailable": "Zoo Gateway: 現在アカウントを利用できません。サポートまでお問い合わせください。", + "budget_exceeded": "Zoo Gateway: 利用予算に達しました。リセットを待つかチャージしてください。" }, "info": { "connected": "Zoo Code: 接続に成功しました!Zoo Code を AI プロバイダーとして使用できます。", "disconnected": "Zoo Code: 正常に切断されました。" + }, + "buttons": { + "sign_in": "サインイン", + "add_credits": "クレジットを追加", + "contact_support": "サポートに連絡" } }, "codeActions": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index edcc4ea312..daf9f69e4c 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: 잘못된 인증 token을 수신했습니다.", "token_verification_failed": "Zoo Code: Token 검증에 실패했습니다.", "invalid_token": "Zoo Code: 잘못된 token입니다.", - "could_not_verify_token": "Zoo Code: Token을 검증할 수 없습니다." + "could_not_verify_token": "Zoo Code: Token을 검증할 수 없습니다.", + "session_expired": "Zoo Code 세션이 만료되었습니다. Zoo Gateway를 계속 사용하려면 다시 로그인해 주세요.", + "out_of_credits": "Zoo Gateway: 크레딧이 부족합니다. 계속하려면 크레딧을 추가하세요.", + "account_unavailable": "Zoo Gateway: 현재 계정을 사용할 수 없습니다. 지원팀에 문의하세요.", + "budget_exceeded": "Zoo Gateway: 사용 한도에 도달했습니다. 한도가 재설정되기를 기다리거나 충전하세요." }, "info": { "connected": "Zoo Code: 연결에 성공했습니다! 이제 Zoo Code를 AI 제공자로 사용할 수 있습니다.", "disconnected": "Zoo Code: 연결이 해제되었습니다." + }, + "buttons": { + "sign_in": "로그인", + "add_credits": "크레딧 추가", + "contact_support": "지원팀 문의" } }, "codeActions": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 7dda89b7eb..3f4c1e984d 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Ongeldig authenticatietoken ontvangen.", "token_verification_failed": "Zoo Code: Tokenverificatie mislukt.", "invalid_token": "Zoo Code: Ongeldig token.", - "could_not_verify_token": "Zoo Code: Token kon niet worden geverifieerd." + "could_not_verify_token": "Zoo Code: Token kon niet worden geverifieerd.", + "session_expired": "Zoo Code-sessie verlopen. Meld je opnieuw aan om Zoo Gateway te blijven gebruiken.", + "out_of_credits": "Zoo Gateway: onvoldoende tegoed. Voeg tegoed toe om door te gaan.", + "account_unavailable": "Zoo Gateway: je account is momenteel niet beschikbaar. Neem contact op met support.", + "budget_exceeded": "Zoo Gateway: gebruiksbudget bereikt. Wacht op de reset of laad bij." }, "info": { "connected": "Zoo Code: Succesvol verbonden! Je kunt Zoo Code nu gebruiken als AI-provider.", "disconnected": "Zoo Code: Succesvol losgekoppeld." + }, + "buttons": { + "sign_in": "Aanmelden", + "add_credits": "Tegoed toevoegen", + "contact_support": "Contact opnemen" } }, "codeActions": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 152193755c..c88aabcaa5 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Otrzymano nieprawidłowy token uwierzytelniania.", "token_verification_failed": "Zoo Code: Weryfikacja tokenu nie powiodła się.", "invalid_token": "Zoo Code: Nieprawidłowy token.", - "could_not_verify_token": "Zoo Code: Nie udało się zweryfikować tokenu." + "could_not_verify_token": "Zoo Code: Nie udało się zweryfikować tokenu.", + "session_expired": "Sesja Zoo Code wygasła. Zaloguj się ponownie, aby kontynuować korzystanie z Zoo Gateway.", + "out_of_credits": "Zoo Gateway: niewystarczające środki. Doładuj, aby kontynuować.", + "account_unavailable": "Zoo Gateway: Twoje konto jest obecnie niedostępne. Skontaktuj się z pomocą techniczną.", + "budget_exceeded": "Zoo Gateway: osiągnięto budżet użycia. Poczekaj na reset lub doładuj." }, "info": { "connected": "Zoo Code: Połączono pomyślnie! Możesz teraz używać Zoo Code jako dostawcy AI.", "disconnected": "Zoo Code: Rozłączono pomyślnie." + }, + "buttons": { + "sign_in": "Zaloguj się", + "add_credits": "Doładuj", + "contact_support": "Kontakt z pomocą" } }, "codeActions": { diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index b72a4b75e9..ce4c51e08d 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Token de autenticação inválido recebido.", "token_verification_failed": "Zoo Code: Falha na verificação do token.", "invalid_token": "Zoo Code: Token inválido.", - "could_not_verify_token": "Zoo Code: Não foi possível verificar o token." + "could_not_verify_token": "Zoo Code: Não foi possível verificar o token.", + "session_expired": "Sessão do Zoo Code expirada. Entre novamente para continuar usando o Zoo Gateway.", + "out_of_credits": "Zoo Gateway: créditos insuficientes. Adicione créditos para continuar.", + "account_unavailable": "Zoo Gateway: sua conta está indisponível no momento. Entre em contato com o suporte.", + "budget_exceeded": "Zoo Gateway: orçamento de uso atingido. Aguarde o reset ou recarregue." }, "info": { "connected": "Zoo Code: Conectado com sucesso! Agora você pode usar o Zoo Code como provedor de IA.", "disconnected": "Zoo Code: Desconectado com sucesso." + }, + "buttons": { + "sign_in": "Entrar", + "add_credits": "Adicionar créditos", + "contact_support": "Contatar suporte" } }, "codeActions": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index f438184d3d..9d096fb346 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Получен недействительный token аутентификации.", "token_verification_failed": "Zoo Code: Не удалось проверить token.", "invalid_token": "Zoo Code: Недействительный token.", - "could_not_verify_token": "Zoo Code: Не удалось проверить token." + "could_not_verify_token": "Zoo Code: Не удалось проверить token.", + "session_expired": "Сессия Zoo Code истекла. Войдите снова, чтобы продолжить использовать Zoo Gateway.", + "out_of_credits": "Zoo Gateway: недостаточно кредитов. Добавьте кредиты, чтобы продолжить.", + "account_unavailable": "Zoo Gateway: ваша учётная запись сейчас недоступна. Обратитесь в поддержку.", + "budget_exceeded": "Zoo Gateway: лимит использования достигнут. Дождитесь сброса или пополните счёт." }, "info": { "connected": "Zoo Code: Успешно подключено! Теперь ты можешь использовать Zoo Code в качестве AI-провайдера.", "disconnected": "Zoo Code: Успешно отключено." + }, + "buttons": { + "sign_in": "Войти", + "add_credits": "Добавить кредиты", + "contact_support": "Связаться с поддержкой" } }, "codeActions": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index b02ce455c7..3d83d28c32 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: Geçersiz kimlik doğrulama token'ı alındı.", "token_verification_failed": "Zoo Code: Token doğrulaması başarısız oldu.", "invalid_token": "Zoo Code: Geçersiz token.", - "could_not_verify_token": "Zoo Code: Token doğrulanamadı." + "could_not_verify_token": "Zoo Code: Token doğrulanamadı.", + "session_expired": "Zoo Code oturumunun süresi doldu. Zoo Gateway'i kullanmaya devam etmek için lütfen tekrar giriş yapın.", + "out_of_credits": "Zoo Gateway: yetersiz kredi. Devam etmek için kredi ekleyin.", + "account_unavailable": "Zoo Gateway: hesabınız şu anda kullanılamıyor. Lütfen destek ile iletişime geçin.", + "budget_exceeded": "Zoo Gateway: kullanım bütçesine ulaşıldı. Sıfırlanmasını bekleyin veya kredi ekleyin." }, "info": { "connected": "Zoo Code: Başarıyla bağlandı! Artık Zoo Code'u AI sağlayıcısı olarak kullanabilirsin.", "disconnected": "Zoo Code: Başarıyla bağlantı kesildi." + }, + "buttons": { + "sign_in": "Giriş Yap", + "add_credits": "Kredi Ekle", + "contact_support": "Destek ile İletişim" } }, "codeActions": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index fbf9d64c14..d111ade94b 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -268,11 +268,20 @@ "invalid_token_received": "Zoo Code: Nhận được token xác thực không hợp lệ.", "token_verification_failed": "Zoo Code: Xác minh token thất bại.", "invalid_token": "Zoo Code: Token không hợp lệ.", - "could_not_verify_token": "Zoo Code: Không thể xác minh token." + "could_not_verify_token": "Zoo Code: Không thể xác minh token.", + "session_expired": "Phiên Zoo Code đã hết hạn. Vui lòng đăng nhập lại để tiếp tục sử dụng Zoo Gateway.", + "out_of_credits": "Zoo Gateway: không đủ tín dụng. Thêm tín dụng để tiếp tục.", + "account_unavailable": "Zoo Gateway: tài khoản của bạn hiện không khả dụng. Vui lòng liên hệ hỗ trợ.", + "budget_exceeded": "Zoo Gateway: đã đạt ngân sách sử dụng. Chờ đặt lại ngân sách hoặc nạp thêm." }, "info": { "connected": "Zoo Code: Kết nối thành công! Bạn có thể sử dụng Zoo Code làm nhà cung cấp AI.", "disconnected": "Zoo Code: Đã ngắt kết nối thành công." + }, + "buttons": { + "sign_in": "Đăng nhập", + "add_credits": "Thêm tín dụng", + "contact_support": "Liên hệ hỗ trợ" } }, "codeActions": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 65a6fac5ac..1ff8d330cf 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -266,11 +266,20 @@ "invalid_token_received": "Zoo Code: 收到无效的认证 token。", "token_verification_failed": "Zoo Code: Token 验证失败。", "invalid_token": "Zoo Code: 无效的 token。", - "could_not_verify_token": "Zoo Code: 无法验证 token。" + "could_not_verify_token": "Zoo Code: 无法验证 token。", + "session_expired": "Zoo Code 会话已过期。请重新登录以继续使用 Zoo Gateway。", + "out_of_credits": "Zoo Gateway:积分不足。请添加积分以继续。", + "account_unavailable": "Zoo Gateway:你的账户当前不可用。请联系支持。", + "budget_exceeded": "Zoo Gateway:已达到使用预算。请等待预算重置或充值。" }, "info": { "connected": "Zoo Code: 连接成功!你现在可以使用 Zoo Code 作为 AI 提供商。", "disconnected": "Zoo Code: 已成功断开连接。" + }, + "buttons": { + "sign_in": "登录", + "add_credits": "添加积分", + "contact_support": "联系支持" } }, "codeActions": { diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index ba9af0cf53..74e661e280 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -261,11 +261,20 @@ "invalid_token_received": "Zoo Code: 收到無效的認證 token。", "token_verification_failed": "Zoo Code: Token 驗證失敗。", "invalid_token": "Zoo Code: 無效的 token。", - "could_not_verify_token": "Zoo Code: 無法驗證 token。" + "could_not_verify_token": "Zoo Code: 無法驗證 token。", + "session_expired": "Zoo Code 工作階段已過期。請重新登入以繼續使用 Zoo Gateway。", + "out_of_credits": "Zoo Gateway:點數不足。請新增點數以繼續。", + "account_unavailable": "Zoo Gateway:您的帳戶目前無法使用。請聯絡支援。", + "budget_exceeded": "Zoo Gateway:已達使用預算。請等待預算重置或加值。" }, "info": { "connected": "Zoo Code: 連線成功!你現在可以使用 Zoo Code 作為 AI 提供商。", "disconnected": "Zoo Code: 已成功中斷連線。" + }, + "buttons": { + "sign_in": "登入", + "add_credits": "新增點數", + "contact_support": "聯絡支援" } }, "codeActions": { diff --git a/src/services/__tests__/zoo-code-auth.test.ts b/src/services/__tests__/zoo-code-auth.test.ts index a182b0a34c..d06d86a226 100644 --- a/src/services/__tests__/zoo-code-auth.test.ts +++ b/src/services/__tests__/zoo-code-auth.test.ts @@ -12,6 +12,7 @@ import { getZooCodeBaseUrl, handleAuthCallback, initZooCodeAuth, + resolveZooGatewaySessionToken, setZooCodeToken, setZooCodeUserInfo, verifyZooCodeToken, @@ -241,6 +242,23 @@ describe("zoo-code-auth", () => { expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") expect(getCachedSubscriptionStatus()).toBe("unknown") }) + + it("preserves token and user info when verify returns 5xx (transient backend error)", async () => { + await mockSecrets.store("zoo-code-session-token", "zoo_ext_valid_token") + await mockSecrets.store("zoo-code-user-name", "Jane Doe") + await mockSecrets.store("zoo-code-user-email", "jane@example.com") + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }) + + await initZooCodeAuth(mockContext) + + expect(getCachedZooCodeToken()).toBe("zoo_ext_valid_token") + expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") + expect(getCachedSubscriptionStatus()).toBe("unknown") + }) }) describe("setZooCodeToken", () => { @@ -364,7 +382,7 @@ describe("zoo-code-auth", () => { expect(getCachedZooCodeToken()).toBe("zoo_ext_invalid_token") }) - it("returns 'invalid' when the backend returns HTTP error", async () => { + it("returns 'invalid' when the backend returns 4xx", async () => { await initZooCodeAuth(mockContext) await setZooCodeToken("zoo_ext_invalid_token") mockFetch.mockResolvedValueOnce({ @@ -376,6 +394,19 @@ describe("zoo-code-auth", () => { expect(await verifyZooCodeToken()).toBe("invalid") }) + it("returns 'unreachable' when the backend returns 5xx (transient)", async () => { + await initZooCodeAuth(mockContext) + await setZooCodeToken("zoo_ext_token") + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }) + + expect(await verifyZooCodeToken()).toBe("unreachable") + expect(getCachedZooCodeToken()).toBe("zoo_ext_token") + }) + it("returns 'unreachable' when a network error occurs", async () => { await initZooCodeAuth(mockContext) await setZooCodeToken("zoo_ext_token") @@ -432,6 +463,29 @@ describe("zoo-code-auth", () => { }) }) + describe("resolveZooGatewaySessionToken", () => { + it("prefers the cached token over a profile token", async () => { + await initZooCodeAuth(mockContext) + await setZooCodeToken("zoo_ext_cached") + + expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_cached") + }) + + it("ignores profile tokens after an explicit sign-out clear", async () => { + await initZooCodeAuth(mockContext) + await setZooCodeToken("zoo_ext_cached") + await clearZooCodeToken() + + expect(resolveZooGatewaySessionToken("zoo_ext_stale_profile")).toBeUndefined() + }) + + it("falls back to the profile token when the cache is empty and not cleared", async () => { + await initZooCodeAuth(mockContext) + + expect(resolveZooGatewaySessionToken("zoo_ext_profile")).toBe("zoo_ext_profile") + }) + }) + describe("disconnectZooCode", () => { it("revokes the current token and clears cached auth state", async () => { await initZooCodeAuth(mockContext) diff --git a/src/services/zoo-code-auth.ts b/src/services/zoo-code-auth.ts index 79fb6610ad..f7587aaf80 100644 --- a/src/services/zoo-code-auth.ts +++ b/src/services/zoo-code-auth.ts @@ -11,6 +11,7 @@ let secretStorage: vscode.SecretStorage | undefined // In-memory cache for synchronous access in ZooCodeHandler hot path let _cachedToken: string | undefined = undefined +let _sessionCleared = false let _cachedUserName: string | undefined = undefined let _cachedUserEmail: string | undefined = undefined let _cachedUserImage: string | undefined = undefined @@ -28,6 +29,7 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise // Pre-load the token and user info into memory on init so ZooCodeHandler can access them synchronously _cachedToken = await secretStorage.get(ZOO_CODE_TOKEN_KEY) + _sessionCleared = false _cachedUserName = await secretStorage.get(ZOO_CODE_USER_NAME_KEY) _cachedUserEmail = await secretStorage.get(ZOO_CODE_USER_EMAIL_KEY) _cachedUserImage = await secretStorage.get(ZOO_CODE_USER_IMAGE_KEY) @@ -85,6 +87,21 @@ export function getCachedZooCodeToken(): string { return _cachedToken ?? "" } +/** + * Resolves the Zoo Gateway session token for API calls. + * Secret-storage cache wins over profile-persisted tokens; after an explicit sign-out + * or 401 clear, profile tokens are ignored so stale credentials cannot be reused. + */ +export function resolveZooGatewaySessionToken(profileToken?: string): string | undefined { + if (_cachedToken) { + return _cachedToken + } + if (_sessionCleared) { + return undefined + } + return profileToken || undefined +} + export function getCachedZooCodeUserInfo(): { name?: string; email?: string; image?: string } { return { name: _cachedUserName, @@ -153,6 +170,7 @@ export async function setZooCodeToken(token: string): Promise { if (!secretStorage) return await secretStorage.store(ZOO_CODE_TOKEN_KEY, token) _cachedToken = token + _sessionCleared = false // Reset subscription status when token is set _cachedSubscriptionStatus = "unknown" _lastSubscriptionCheck = 0 @@ -204,6 +222,7 @@ export async function clearZooCodeToken(): Promise { if (!secretStorage) return await secretStorage.delete(ZOO_CODE_TOKEN_KEY) _cachedToken = undefined + _sessionCleared = true _cachedSubscriptionStatus = "unknown" _lastSubscriptionCheck = 0 } @@ -226,7 +245,13 @@ export async function handleAuthCallback(token: string): Promise { signal: AbortSignal.timeout(10_000), }) if (!response.ok) { - vscode.window.showErrorMessage(t("common:zooAuth.errors.token_verification_failed")) + // Treat 5xx as a transient backend issue (e.g. DB unreachable) so the + // user can retry sign-in instead of being told the token is bad. + if (response.status >= 500) { + vscode.window.showErrorMessage(t("common:zooAuth.errors.could_not_verify_token")) + } else { + vscode.window.showErrorMessage(t("common:zooAuth.errors.token_verification_failed")) + } return false } const data = (await response.json()) as { valid?: boolean } @@ -252,8 +277,12 @@ export async function handleAuthCallback(token: string): Promise { * Verify the stored token against the backend. * Returns: * - "valid" — backend confirmed the token is good - * - "invalid" — backend explicitly rejected the token (HTTP error or valid: false) - * - "unreachable" — network error / timeout; token state is unknown + * - "invalid" — backend explicitly rejected the token (4xx or valid: false) + * - "unreachable" — network error / timeout / 5xx backend error; token state is unknown + * + * 5xx responses are treated as transient: the website returns 503 when the + * database is unreachable, and clearing a real session on a backend hiccup + * forces users to sign in again every time the API blips. * * This function has no side-effects; callers are responsible for acting on the result. */ @@ -270,6 +299,9 @@ export async function verifyZooCodeToken(): Promise<"valid" | "invalid" | "unrea }) if (!response.ok) { + if (response.status >= 500) { + return "unreachable" + } return "invalid" } diff --git a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx index fdde35320f..26033739d5 100644 --- a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx +++ b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx @@ -20,14 +20,17 @@ const DEFAULT_WELCOME_API_CONFIGURATION: ProviderSettings = { openRouterModelId: openRouterDefaultModelId, } -const getWelcomeApiConfiguration = (apiConfiguration?: ProviderSettings): ProviderSettings => { +const getWelcomeApiConfiguration = ( + apiConfiguration?: ProviderSettings, + zooCodeIsAuthenticated?: boolean, +): ProviderSettings => { // validateApiConfiguration treats a missing apiProvider as valid (no switch case matches), // so we explicitly fall back here before delegating to it for incomplete-but-set configs. if (!apiConfiguration?.apiProvider) { return DEFAULT_WELCOME_API_CONFIGURATION } - const validationError = validateApiConfiguration(apiConfiguration) + const validationError = validateApiConfiguration(apiConfiguration, undefined, undefined, zooCodeIsAuthenticated) if (validationError) { return DEFAULT_WELCOME_API_CONFIGURATION } @@ -42,7 +45,8 @@ const WelcomeViewProvider = () => { const [errorMessage, setErrorMessage] = useState(undefined) const [showProviderSetup, setShowProviderSetup] = useState(false) const [welcomeApiConfiguration, setWelcomeApiConfiguration] = useState() - const effectiveApiConfiguration = welcomeApiConfiguration ?? getWelcomeApiConfiguration(apiConfiguration) + const effectiveApiConfiguration = + welcomeApiConfiguration ?? getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated) const setApiConfigurationFieldForApiOptions = useCallback( (field: K, value: ProviderSettings[K]) => { @@ -57,7 +61,7 @@ const WelcomeViewProvider = () => { const handleGetStarted = useCallback(() => { if (!showProviderSetup) { - const initialApiConfiguration = getWelcomeApiConfiguration(apiConfiguration) + const initialApiConfiguration = getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated) setWelcomeApiConfiguration(initialApiConfiguration) setApiConfiguration(initialApiConfiguration) From 52a8cc0395e38fad26ecbdb403f6c6add2c015a6 Mon Sep 17 00:00:00 2001 From: James Mtendamema <59908268+JamesRobert20@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:36:46 -0600 Subject: [PATCH 9/9] feat(privacy): remove extension LLM telemetry; gateway-only server logs (#346) * feat(privacy): remove extension LLM telemetry and observability section Delete the extension-side LLM telemetry path (zoo-telemetry.ts and its Task.ts hook) so BYOK requests no longer send usage metadata to zoocode.dev. Drop the "Zoo Code Observability" section from PRIVACY.md entirely: server-side gateway request logging is not extension behavior and is documented at zoocode.dev like any other provider's, so the extension privacy policy should not single it out. Co-authored-by: Cursor * refactor(zoo-code-auth): drop subscription-status helpers with telemetry The checkSubscriptionStatus / getCachedSubscriptionStatus helpers existed only to gate the now-removed LLM telemetry path. Remove them and their tests. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- PRIVACY.md | 7 - src/core/task/Task.ts | 21 --- src/services/__tests__/zoo-code-auth.test.ts | 147 ++----------------- src/services/__tests__/zoo-telemetry.test.ts | 132 ----------------- src/services/zoo-code-auth.ts | 78 +--------- src/services/zoo-telemetry.ts | 63 -------- 6 files changed, 11 insertions(+), 437 deletions(-) delete mode 100644 src/services/__tests__/zoo-telemetry.test.ts delete mode 100644 src/services/zoo-telemetry.ts diff --git a/PRIVACY.md b/PRIVACY.md index 25db3a8813..2f589cf3ea 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -40,13 +40,6 @@ go—and, importantly, where they don't. We retain telemetry only as long as needed for product analytics and debugging. Telemetry does **not** collect your code or AI prompts, and you can opt out at any time through the settings. -- **Zoo Code Observability (Authenticated Subscribers Only):** If you sign in to - Zoo Code and have an active subscription, Zoo Code will send LLM usage - telemetry to the Zoo Code backend (zoocode.dev). This includes task ID, AI - provider name, model name, token counts (input/output/cache), and estimated - cost. This data is linked to your authenticated Zoo Code account. You can stop - this collection at any time by signing out via the Zoo Code badge in the chat - area. - **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API call to Zoo Code's backend servers to retrieve listing information. These diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 454109778b..1307d88e5a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3013,27 +3013,6 @@ export class Task extends EventEmitter implements TaskLike { cacheReadTokens: tokens.cacheRead, cost: tokens.total ?? costResult.totalCost, }) - - // Zoo Code observability telemetry - import("../../services/zoo-telemetry") - .then(async ({ sendLlmTelemetry }) => { - const mode = await this.getTaskMode().catch(() => "unknown") - return sendLlmTelemetry({ - taskId: this.taskId, - provider: this.apiConfiguration?.apiProvider ?? "unknown", - model: this.apiConfiguration - ? (getModelId(this.apiConfiguration) ?? "unknown") - : "unknown", - mode, - inputTokens: costResult.totalInputTokens, - outputTokens: costResult.totalOutputTokens, - cacheReadTokens: tokens.cacheRead ?? 0, - cacheWriteTokens: tokens.cacheWrite ?? 0, - totalCost: tokens.total ?? costResult.totalCost, - status, - }).catch(() => {}) - }) - .catch(() => {}) } } diff --git a/src/services/__tests__/zoo-code-auth.test.ts b/src/services/__tests__/zoo-code-auth.test.ts index d06d86a226..c08fcdedf3 100644 --- a/src/services/__tests__/zoo-code-auth.test.ts +++ b/src/services/__tests__/zoo-code-auth.test.ts @@ -2,11 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import * as vscode from "vscode" import { - checkSubscriptionStatus, clearZooCodeToken, clearZooCodeUserInfo, disconnectZooCode, - getCachedSubscriptionStatus, getCachedZooCodeToken, getCachedZooCodeUserInfo, getZooCodeBaseUrl, @@ -68,98 +66,6 @@ describe("zoo-code-auth", () => { vi.restoreAllMocks() }) - describe("getCachedSubscriptionStatus", () => { - it("returns unknown initially", () => { - expect(getCachedSubscriptionStatus()).toBe("unknown") - }) - }) - - describe("checkSubscriptionStatus", () => { - it("returns inactive when no token is present", async () => { - await initZooCodeAuth(mockContext) - - const status = await checkSubscriptionStatus() - - expect(status).toBe("inactive") - expect(mockFetch).not.toHaveBeenCalled() - }) - - it("returns active when the API reports an active subscriber", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - - const status = await checkSubscriptionStatus() - - expect(status).toBe("active") - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/subscription/status"), - expect.objectContaining({ - headers: { Authorization: "Bearer zoo_ext_test_token" }, - }), - ) - }) - - it("returns inactive when the API reports a free user", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: false, planId: "free", status: "active" }), - }) - - await expect(checkSubscriptionStatus()).resolves.toBe("inactive") - }) - - it("returns unknown when the API request fails", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - - it("returns unknown when the API throws", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockRejectedValueOnce(new Error("Network error")) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - - it("reuses the cached status when it was checked recently", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - - expect(await checkSubscriptionStatus()).toBe("active") - expect(await checkSubscriptionStatus()).toBe("active") - expect(mockFetch).toHaveBeenCalledTimes(1) - }) - - it("handles AbortSignal timeouts", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockRejectedValueOnce(new DOMException("Aborted", "AbortError")) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - }) - describe("getCachedZooCodeToken", () => { it("returns an empty string when no token is set", async () => { await clearZooCodeToken() @@ -169,15 +75,10 @@ describe("zoo-code-auth", () => { it("preloads the cached token during initialization", async () => { await mockSecrets.store("zoo-code-session-token", "zoo_ext_cached_token") - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ valid: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true }), - }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ valid: true }), + }) await initZooCodeAuth(mockContext) await Promise.resolve() @@ -237,10 +138,8 @@ describe("zoo-code-auth", () => { await initZooCodeAuth(mockContext) - // Token and user info should be kept; subscription status should be unknown expect(getCachedZooCodeToken()).toBe("zoo_ext_valid_token") expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") - expect(getCachedSubscriptionStatus()).toBe("unknown") }) it("preserves token and user info when verify returns 5xx (transient backend error)", async () => { @@ -257,39 +156,16 @@ describe("zoo-code-auth", () => { expect(getCachedZooCodeToken()).toBe("zoo_ext_valid_token") expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") - expect(getCachedSubscriptionStatus()).toBe("unknown") - }) - }) - - describe("setZooCodeToken", () => { - it("resets the cached subscription status when the token changes", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_token1") - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - await checkSubscriptionStatus() - - await setZooCodeToken("zoo_ext_token2") - - expect(getCachedSubscriptionStatus()).toBe("unknown") }) }) describe("clearZooCodeToken", () => { - it("resets the cached subscription status when the token is cleared", async () => { + it("clears the cached token", async () => { await initZooCodeAuth(mockContext) await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - await checkSubscriptionStatus() await clearZooCodeToken() - expect(getCachedSubscriptionStatus()).toBe("unknown") expect(getCachedZooCodeToken()).toBe("") }) }) @@ -337,15 +213,10 @@ describe("zoo-code-auth", () => { it("persists a token only after backend verification succeeds", async () => { await initZooCodeAuth(mockContext) - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ valid: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true }), - }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ valid: true }), + }) const success = await handleAuthCallback("zoo_ext_real_token") diff --git a/src/services/__tests__/zoo-telemetry.test.ts b/src/services/__tests__/zoo-telemetry.test.ts deleted file mode 100644 index 5f5ace80de..0000000000 --- a/src/services/__tests__/zoo-telemetry.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const { - mockCheckSubscriptionStatus, - mockGetCachedSubscriptionStatus, - mockGetCachedZooCodeToken, - mockGetZooCodeBaseUrl, -} = vi.hoisted(() => ({ - mockCheckSubscriptionStatus: vi.fn(), - mockGetCachedSubscriptionStatus: vi.fn(), - mockGetCachedZooCodeToken: vi.fn(), - mockGetZooCodeBaseUrl: vi.fn(), -})) - -vi.mock("../zoo-code-auth", () => ({ - checkSubscriptionStatus: mockCheckSubscriptionStatus, - getCachedSubscriptionStatus: mockGetCachedSubscriptionStatus, - getCachedZooCodeToken: mockGetCachedZooCodeToken, - getZooCodeBaseUrl: mockGetZooCodeBaseUrl, -})) - -import { sendLlmTelemetry } from "../zoo-telemetry" - -describe("sendLlmTelemetry", () => { - const payload = { - taskId: "task-123", - provider: "anthropic", - model: "claude-sonnet-4", - mode: "code", - inputTokens: 11, - outputTokens: 7, - cacheReadTokens: 3, - cacheWriteTokens: 5, - totalCost: 1.23, - } - - beforeEach(() => { - vi.clearAllMocks() - mockGetZooCodeBaseUrl.mockReturnValue("https://www.zoocode.dev") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("skips telemetry when there is no cached token", async () => { - mockGetCachedZooCodeToken.mockReturnValue("") - global.fetch = vi.fn() - - await sendLlmTelemetry(payload) - - expect(global.fetch).not.toHaveBeenCalled() - }) - - it("refreshes an unknown subscription status before sending", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("unknown") - mockCheckSubscriptionStatus.mockResolvedValue("inactive") - global.fetch = vi.fn() - - await sendLlmTelemetry(payload) - - expect(mockCheckSubscriptionStatus).toHaveBeenCalled() - expect(global.fetch).not.toHaveBeenCalled() - }) - - it("fires the observability request without waiting for it to settle", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - let resolveFetch: ((value: unknown) => void) | undefined - global.fetch = vi.fn( - () => - new Promise((resolve) => { - resolveFetch = resolve - }), - ) as typeof fetch - - const result = await Promise.race([ - sendLlmTelemetry(payload).then(() => "resolved"), - new Promise((resolve) => setTimeout(() => resolve("timeout"), 20)), - ]) - - expect(result).toBe("resolved") - expect(global.fetch).toHaveBeenCalledWith( - "https://www.zoocode.dev/api/observability/events", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer zoo_ext_test_token", - }, - signal: expect.any(AbortSignal), - }), - ) - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - ...payload, - status: "completed", - editor: "vscode", - }) - - resolveFetch?.({ ok: true }) - }) - - it("sends cancelled status when provided in payload", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - global.fetch = vi.fn().mockResolvedValue({ ok: true }) - - await sendLlmTelemetry({ ...payload, status: "cancelled" }) - - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - ...payload, - status: "cancelled", - editor: "vscode", - }) - }) - - it("defaults to completed status when not provided", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - global.fetch = vi.fn().mockResolvedValue({ ok: true }) - - await sendLlmTelemetry(payload) - - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - status: "completed", - }) - }) -}) diff --git a/src/services/zoo-code-auth.ts b/src/services/zoo-code-auth.ts index f7587aaf80..709cb805c3 100644 --- a/src/services/zoo-code-auth.ts +++ b/src/services/zoo-code-auth.ts @@ -15,9 +15,6 @@ let _sessionCleared = false let _cachedUserName: string | undefined = undefined let _cachedUserEmail: string | undefined = undefined let _cachedUserImage: string | undefined = undefined -let _cachedSubscriptionStatus: "active" | "inactive" | "unknown" = "unknown" -let _lastSubscriptionCheck: number = 0 -const SUBSCRIPTION_CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise { if (!context.secrets) { @@ -35,19 +32,13 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise _cachedUserImage = await secretStorage.get(ZOO_CODE_USER_IMAGE_KEY) // Validate persisted auth state on init before reporting the user as connected. + // Network errors / 5xx ("unreachable") leave the cached session in place so a + // transient backend blip doesn't force users to sign in again. if (_cachedToken) { const result = await verifyZooCodeToken() if (result === "invalid") { - // Token is definitively rejected by the backend — clear everything. await clearZooCodeUserInfo() await clearZooCodeToken() - } else if (result === "unreachable") { - // Network is temporarily down; keep the cached session but mark subscription - // status as unknown so callers know it hasn't been confirmed. - _cachedSubscriptionStatus = "unknown" - } else { - // result === "valid" - void checkSubscriptionStatus().catch(() => {}) } } @@ -56,12 +47,6 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise if (e.key === ZOO_CODE_TOKEN_KEY) { secretStorage?.get(ZOO_CODE_TOKEN_KEY).then((token) => { _cachedToken = token - // Reset subscription status when token changes - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 - if (token) { - checkSubscriptionStatus().catch(() => {}) - } }) } if (e.key === ZOO_CODE_USER_NAME_KEY) { @@ -110,57 +95,6 @@ export function getCachedZooCodeUserInfo(): { name?: string; email?: string; ima } } -/** - * Get the cached subscription status. This is a synchronous getter that returns - * the last known subscription status. Call checkSubscriptionStatus() to refresh. - */ -export function getCachedSubscriptionStatus(): "active" | "inactive" | "unknown" { - return _cachedSubscriptionStatus -} - -/** - * Check the subscription status from the backend API. - * Updates the cached status and returns it. - * Implements caching to avoid excessive API calls (5 minute cache). - */ -export async function checkSubscriptionStatus(): Promise<"active" | "inactive" | "unknown"> { - const token = await getZooCodeToken() - if (!token) { - _cachedSubscriptionStatus = "inactive" - return "inactive" - } - - // Return cached status if checked recently - const now = Date.now() - if (now - _lastSubscriptionCheck < SUBSCRIPTION_CHECK_INTERVAL_MS && _cachedSubscriptionStatus !== "unknown") { - return _cachedSubscriptionStatus - } - - const baseUrl = getZooCodeBaseUrl() - - try { - const response = await fetch(`${baseUrl}/api/subscription/status`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(10_000), - }) - - if (!response.ok) { - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = now - return "unknown" - } - - const data = (await response.json()) as { isSubscriber?: boolean } - _cachedSubscriptionStatus = data.isSubscriber ? "active" : "inactive" - _lastSubscriptionCheck = now - return _cachedSubscriptionStatus - } catch { - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = now - return "unknown" - } -} - export async function getZooCodeToken(): Promise { if (!secretStorage) return undefined return secretStorage.get(ZOO_CODE_TOKEN_KEY) @@ -171,9 +105,6 @@ export async function setZooCodeToken(token: string): Promise { await secretStorage.store(ZOO_CODE_TOKEN_KEY, token) _cachedToken = token _sessionCleared = false - // Reset subscription status when token is set - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 } export async function setZooCodeUserInfo(info: { @@ -223,8 +154,6 @@ export async function clearZooCodeToken(): Promise { await secretStorage.delete(ZOO_CODE_TOKEN_KEY) _cachedToken = undefined _sessionCleared = true - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 } export function getZooCodeBaseUrl(): string { @@ -266,9 +195,6 @@ export async function handleAuthCallback(token: string): Promise { await setZooCodeToken(token) - // Check subscription status after successful auth - await checkSubscriptionStatus().catch(() => {}) - vscode.window.showInformationMessage(t("common:zooAuth.info.connected")) return true } diff --git a/src/services/zoo-telemetry.ts b/src/services/zoo-telemetry.ts deleted file mode 100644 index b181ff6d5b..0000000000 --- a/src/services/zoo-telemetry.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - getCachedZooCodeToken, - getZooCodeBaseUrl, - getCachedSubscriptionStatus, - checkSubscriptionStatus, -} from "./zoo-code-auth" -import { Package } from "../shared/package" - -export type LlmTelemetryPayload = { - taskId: string - provider: string - model: string - mode?: string - inputTokens: number - outputTokens: number - cacheReadTokens?: number - cacheWriteTokens?: number - totalCost?: number - status?: "completed" | "cancelled" -} - -/** - * Send LLM telemetry to the Zoo Code observability backend. - * This is a fire-and-forget operation that silently fails on error. - * Only sends telemetry for authenticated users with active subscriptions. - */ -export async function sendLlmTelemetry(payload: LlmTelemetryPayload): Promise { - const token = getCachedZooCodeToken() - if (!token) { - return - } - - // Check subscription status before sending (uses 5-minute cache) - let status = getCachedSubscriptionStatus() - if (status === "unknown") { - status = await checkSubscriptionStatus().catch(() => "unknown" as const) - } - - if (status !== "active") { - return - } - - const baseUrl = getZooCodeBaseUrl() - - const body = { - ...payload, - status: payload.status ?? "completed", - extensionVersion: Package.version, - editor: "vscode", - } - - void fetch(`${baseUrl}/api/observability/events`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(10_000), - }).catch(() => { - // Silently ignore errors - telemetry should never impact user experience - }) -}