From ca4fef58f8efaf04d0f37999813d5f886d69da64 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 12:53:13 -0700 Subject: [PATCH 01/33] feat(api): add GitHub Copilot provider with sign-in and live model capabilities --- README.md | 39 ++ apps/cli/src/lib/utils/context-window.ts | 1 + .../__tests__/provider-default-model.test.ts | 4 + .../__tests__/provider-identifiers.test.ts | 3 +- .../src/__tests__/provider-model-id.test.ts | 42 +- packages/types/src/mcp.ts | 6 + packages/types/src/model.ts | 1 + packages/types/src/provider-identifiers.ts | 1 + packages/types/src/provider-settings.ts | 7 +- packages/types/src/provider-settings/index.ts | 3 +- .../types/src/provider-settings/vscode-lm.ts | 30 +- packages/types/src/providers/index.ts | 1 + packages/types/src/providers/vscode-llm.ts | 37 +- packages/types/src/vscode-extension-host.ts | 18 +- src/api/__tests__/index.spec.ts | 6 + src/api/index.ts | 3 + .../__tests__/github-copilot.spec.ts | 444 ++++++++++++++++++ src/api/providers/__tests__/vscode-lm.spec.ts | 74 +++ src/api/providers/github-copilot.ts | 140 ++++++ src/api/providers/index.ts | 1 + src/api/providers/vscode-lm-capabilities.ts | 57 +++ src/api/providers/vscode-lm.ts | 177 ++++--- .../__tests__/vscode-lm-format.spec.ts | 70 +++ src/api/transform/vscode-lm-format.ts | 42 +- src/api/transform/vscode-lm-image-part.ts | 36 ++ src/core/task/Task.ts | 7 +- src/core/task/__tests__/Task.spec.ts | 40 ++ src/core/webview/ClineProvider.ts | 1 - .../__tests__/webviewMessageHandler.spec.ts | 96 ++++ src/core/webview/webviewMessageHandler.ts | 60 ++- src/extension.ts | 8 + .../__tests__/copilotModelBroadcast.spec.ts | 166 +++++++ .../language-models/copilotModelBroadcast.ts | 78 +++ src/shared/ProfileValidator.ts | 1 + src/shared/__tests__/ProfileValidator.spec.ts | 15 + .../components/chat/TooManyToolsWarning.tsx | 5 +- .../__tests__/TooManyToolsWarning.spec.tsx | 36 ++ .../src/components/settings/ApiOptions.tsx | 17 +- .../src/components/settings/ModelInfoView.tsx | 39 +- .../ApiOptions.provider-filtering.spec.tsx | 9 + .../settings/__tests__/ModelInfoView.spec.tsx | 60 +++ .../src/components/settings/constants.ts | 1 + .../settings/providers/VSCodeLM.tsx | 190 +++++++- .../__tests__/ProviderRouting.spec.tsx | 437 ++++++++++++++++- .../settings/utils/providerModelConfig.ts | 1 + .../__tests__/useGitHubCopilotModels.spec.tsx | 94 ++++ .../hooks/__tests__/useSelectedModel.spec.ts | 95 ++++ .../ui/hooks/useGitHubCopilotModels.ts | 43 ++ .../components/ui/hooks/useSelectedModel.ts | 33 ++ webview-ui/src/hooks/useTooManyTools.ts | 13 +- webview-ui/src/i18n/locales/ca/settings.json | 13 + webview-ui/src/i18n/locales/de/settings.json | 13 + webview-ui/src/i18n/locales/en/settings.json | 13 + webview-ui/src/i18n/locales/es/settings.json | 13 + webview-ui/src/i18n/locales/fr/settings.json | 13 + webview-ui/src/i18n/locales/hi/settings.json | 13 + webview-ui/src/i18n/locales/id/settings.json | 13 + webview-ui/src/i18n/locales/it/settings.json | 13 + webview-ui/src/i18n/locales/ja/settings.json | 13 + webview-ui/src/i18n/locales/ko/settings.json | 13 + webview-ui/src/i18n/locales/nl/settings.json | 13 + webview-ui/src/i18n/locales/pl/settings.json | 13 + .../src/i18n/locales/pt-BR/settings.json | 13 + webview-ui/src/i18n/locales/ru/settings.json | 13 + webview-ui/src/i18n/locales/tr/settings.json | 13 + webview-ui/src/i18n/locales/vi/settings.json | 13 + .../src/i18n/locales/zh-CN/settings.json | 13 + .../src/i18n/locales/zh-TW/settings.json | 13 + webview-ui/src/utils/validate.ts | 9 + 69 files changed, 2871 insertions(+), 160 deletions(-) create mode 100644 src/api/providers/__tests__/github-copilot.spec.ts create mode 100644 src/api/providers/github-copilot.ts create mode 100644 src/api/providers/vscode-lm-capabilities.ts create mode 100644 src/api/transform/vscode-lm-image-part.ts create mode 100644 src/services/language-models/__tests__/copilotModelBroadcast.spec.ts create mode 100644 src/services/language-models/copilotModelBroadcast.ts create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useGitHubCopilotModels.spec.tsx create mode 100644 webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts diff --git a/README.md b/README.md index fced229c9b..079164b2fc 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,45 @@ Zoo Code builds on the foundation created by Roo Code and continues to expand it - **More dependable terminal and editing workflows** — fixes for premature terminal completion, task-state races, context management, diff editing, and provider-specific tool use. - **More control over your workspace** — rules management, per-mode MCP restrictions, multi-root path controls, model reasoning options, and completion change review actions. +## GitHub Copilot + +Select **GitHub Copilot** in Zoo Code's provider settings and click **Sign in to GitHub**. GitHub +Copilot Chat must be installed and enabled. Zoo Code shows the signed-in account and refreshes the +model list as Copilot registers models. Select a model and save the profile. VS Code may ask you to +allow Zoo Code to use the model on the first request. + +Signing in proves a GitHub identity, not a Copilot subscription. An account without Copilot access +signs in successfully and simply sees no models. + +- **Reconnect GitHub** requests a fresh authorization instead of reusing the existing session. +- **Manage GitHub account** opens VS Code's own account menu. VS Code offers extensions no way to + sign out on a user's behalf, so signing out happens there. It affects Copilot and every other + extension using the account. + +VS Code manages the GitHub session; Zoo Code never displays or stores the access token. + +### Capabilities + +Capabilities come from what VS Code reports for each Copilot model, so new models work without code +changes, and nothing about a model is saved into your settings where it could go stale. + +- **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one + is known. +- **Images:** read from the host's reported capability, falling back to Zoo Code's curated catalog + only when the host reports nothing. A capability that neither states is shown as **Image support + not reported** rather than assumed unsupported. Images are sent as image data, not text + placeholders, and need a VS Code version that supports image parts; Zoo Code's minimum supported + VS Code version stays 1.100. +- **Tools:** VS Code tells extensions only whether a model supports tools, not how many it accepts, + so Zoo Code cannot enforce a per-model limit. The same advisory shown for any provider applies when + many MCP tools are enabled. GitHub plan limits, premium-request allowances, organization policies, + and backend limits still apply. +- **Prompt caching:** Copilot owns any backend caching. The public VS Code Language Model API exposes + no cache-control directives or cache-hit usage, so Zoo Code neither enables caching nor reports + savings; the provider-managed label states this and does not promise a cache hit. + +The existing **VS Code LM API** provider remains available for models from other extensions. + ## What's New in v3.88.0 - ✨ **Provider and model support:** Use Grok Build and improved custom-model support. This release also fixes Bedrock model limits and sends the Codex verbosity setting. diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index b78ab0e5b5..e429627243 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -91,6 +91,7 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { case retiredProviderIdentifiers.ioIntelligence: case retiredProviderIdentifiers.roo: case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: case providerIdentifiers.fakeAi: case undefined: return config.apiModelId diff --git a/packages/types/src/__tests__/provider-default-model.test.ts b/packages/types/src/__tests__/provider-default-model.test.ts index 9957603dda..7578391bac 100644 --- a/packages/types/src/__tests__/provider-default-model.test.ts +++ b/packages/types/src/__tests__/provider-default-model.test.ts @@ -34,6 +34,10 @@ describe("getProviderDefaultModelId", () => { expect(getProviderDefaultModelId(providerIdentifiers.vscodeLm)).toBe(vscodeLlmDefaultModelId) }) + it("gives GitHub Copilot the same default as the VS Code LM provider", () => { + expect(getProviderDefaultModelId(providerIdentifiers.githubCopilot)).toBe(vscodeLlmDefaultModelId) + }) + it("uses the canonical OpenAI Native default model", () => { expect(getProviderDefaultModelId(providerIdentifiers.openaiNative)).toBe(openAiNativeDefaultModelId) }) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index d9ba4fb799..bba6571b05 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -38,6 +38,7 @@ const expectedProviderIdentifiers = [ "ollama", "lmstudio", "vscode-lm", + "github-copilot", "openai", "fake-ai", "anthropic", @@ -114,7 +115,7 @@ describe("provider identifiers", () => { providerIdentifiers.kimiCode, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) - expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) + expect(internalProviders).toEqual([providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot]) expect(customProviders).toEqual([providerIdentifiers.openai]) expect(fauxProviders).toEqual([providerIdentifiers.fakeAi]) }) diff --git a/packages/types/src/__tests__/provider-model-id.test.ts b/packages/types/src/__tests__/provider-model-id.test.ts index a623b24b27..e25341e4c3 100644 --- a/packages/types/src/__tests__/provider-model-id.test.ts +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -1,5 +1,11 @@ import { retiredProviderIdentifiers } from "../provider-identifiers.js" -import { getModelId, modelIdKeys, providerIdentifiers, type ProviderSettings } from "../index.js" +import { + getModelId, + modelIdKeys, + providerIdentifiers, + providerSettingsSchema, + type ProviderSettings, +} from "../index.js" const expectedModelIdKeys = [ "apiModelId", @@ -54,6 +60,40 @@ describe("getModelId", () => { expect(getModelId(settings)).toBe("vscode-model") }) + it("preserves the exact Copilot model ID through the provider settings schema", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o", id: "copilot-model", version: "1" }, + } + expect(getModelId(settings)).toBe("copilot-model") + }) + + it("persists only the selector's identity, never live model capabilities", () => { + const saved = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "dynamic-model", family: "dynamic-model", version: "1" }, + }) + expect(saved.vsCodeLmModelSelector).toEqual({ + vendor: "copilot", + id: "dynamic-model", + family: "dynamic-model", + version: "1", + }) + }) + + it("drops a stale capability snapshot from profiles saved by earlier builds", () => { + const saved = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { + vendor: "copilot", + id: "old-model", + modelInfo: { contextWindow: 1, supportsImages: false, supportsPromptCache: false }, + }, + }) + expect(getModelId(saved)).toBe("old-model") + expect(saved.vsCodeLmModelSelector).not.toHaveProperty("modelInfo") + }) + it("uses openAiModelId for OpenAI Compatible", () => { const settings: ProviderSettings = { apiProvider: providerIdentifiers.openai, diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts index ce23cadd32..8cd5d8c34d 100644 --- a/packages/types/src/mcp.ts +++ b/packages/types/src/mcp.ts @@ -6,6 +6,12 @@ import { z } from "zod" */ export const MAX_MCP_TOOLS_THRESHOLD = 60 +/** A provider-supplied `maxTools` replaces the generic advisory, so the warning tracks a real limit. */ +export function getMcpToolThreshold(modelInfo?: { maxTools?: number }): number { + const limit = modelInfo?.maxTools + return limit !== undefined && Number.isSafeInteger(limit) && limit > 0 ? limit : MAX_MCP_TOOLS_THRESHOLD +} + /** * McpServerUse */ diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 3f4081e78d..1b3b1250b2 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -102,6 +102,7 @@ export const isModelParameter = (value: string): value is ModelParameter => export const modelInfoSchema = z.object({ maxTokens: z.number().nullish(), maxThinkingTokens: z.number().nullish(), + maxTools: z.number().int().positive().optional(), contextWindow: z.number(), supportsImages: z.boolean().optional(), supportsPromptCache: z.boolean(), diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index fdf507bb6b..55bbac0ae2 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -18,6 +18,7 @@ export const providerIdentifiers = { ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", + githubCopilot: "github-copilot", openai: "openai", fakeAi: "fake-ai", anthropic: "anthropic", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index d664f4903c..456394d6d3 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -126,7 +126,7 @@ export const isLocalProvider = (key: string): key is LocalProvider => localProvi * model list. */ -export const internalProviders = [providerIdentifiers.vscodeLm] as const +export const internalProviders = [providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot] as const export type InternalProvider = (typeof internalProviders)[number] @@ -516,6 +516,11 @@ export const MODELS_BY_PROVIDER: Record< label: "VS Code LM API", models: Object.keys(vscodeLlmModels), }, + [providerIdentifiers.githubCopilot]: { + id: providerIdentifiers.githubCopilot, + label: "GitHub Copilot", + models: Object.keys(vscodeLlmModels), + }, [providerIdentifiers.xai]: { id: providerIdentifiers.xai, label: "xAI (Grok)", models: Object.keys(xaiModels) }, [providerIdentifiers.zai]: { id: providerIdentifiers.zai, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 5cdadb4b2c..6252199725 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -4,7 +4,7 @@ import { bedrockProviderDefinition } from "./bedrock.js" import { vertexProviderDefinition } from "./vertex.js" import { openAiProviderDefinition } from "./openai.js" import { ollamaProviderDefinition } from "./ollama.js" -import { vsCodeLmProviderDefinition } from "./vscode-lm.js" +import { githubCopilotProviderDefinition, vsCodeLmProviderDefinition } from "./vscode-lm.js" import { lmStudioProviderDefinition } from "./lm-studio.js" import { geminiProviderDefinition } from "./gemini.js" import { geminiCliProviderDefinition } from "./gemini-cli.js" @@ -56,6 +56,7 @@ export const providerDefinitionList = [ openAiProviderDefinition, ollamaProviderDefinition, vsCodeLmProviderDefinition, + githubCopilotProviderDefinition, lmStudioProviderDefinition, geminiProviderDefinition, geminiCliProviderDefinition, diff --git a/packages/types/src/provider-settings/vscode-lm.ts b/packages/types/src/provider-settings/vscode-lm.ts index 8579d69a5b..f666e6c8b3 100644 --- a/packages/types/src/provider-settings/vscode-lm.ts +++ b/packages/types/src/provider-settings/vscode-lm.ts @@ -3,18 +3,26 @@ import { z } from "zod" import { providerIdentifiers } from "../provider-identifiers.js" import { baseProviderSettingsShape, createProviderDefinition } from "./common.js" +const schema = { + ...baseProviderSettingsShape, + vsCodeLmModelSelector: z + .object({ + vendor: z.string().optional(), + family: z.string().optional(), + version: z.string().optional(), + id: z.string().optional(), + }) + .optional(), +} + export const vsCodeLmProviderDefinition = createProviderDefinition({ apiProvider: providerIdentifiers.vscodeLm, getModelId: (settings) => settings.vsCodeLmModelSelector?.id, - schema: { - ...baseProviderSettingsShape, - vsCodeLmModelSelector: z - .object({ - vendor: z.string().optional(), - family: z.string().optional(), - version: z.string().optional(), - id: z.string().optional(), - }) - .optional(), - }, + schema, +}) + +export const githubCopilotProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.githubCopilot, + getModelId: (settings) => settings.vsCodeLmModelSelector?.id, + schema, }) diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index ed0c583912..da0558ddb5 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -116,6 +116,7 @@ export function getProviderDefaultModelId( case providerIdentifiers.lmstudio: return NO_DEFAULT_MODEL_ID case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: return vscodeLlmDefaultModelId case providerIdentifiers.sambanova: return sambaNovaDefaultModelId diff --git a/packages/types/src/providers/vscode-llm.ts b/packages/types/src/providers/vscode-llm.ts index b0c0072412..4fc21af223 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -1,8 +1,17 @@ import { z } from "zod" import type { ModelInfo } from "../model.js" +import { openAiModelInfoSaneDefaults } from "./openai.js" -export const vsCodeLmModelsMessageTypes = ["requestVsCodeLmModels", "vsCodeLmModels"] as const +export const vsCodeLmModelsMessageTypes = [ + "requestVsCodeLmModels", + "vsCodeLmModels", + "githubCopilotSignIn", + "githubCopilotReconnect", + "githubCopilotManageAccount", + "githubCopilotSignInResult", + "githubCopilotModels", +] as const export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes) @@ -10,6 +19,32 @@ export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum export type VsCodeLmModelsMessageType = z.infer +const githubCopilotVendor = "copilot" + +/** Identifiers for the language models and sign-in flow GitHub Copilot contributes to VS Code. */ +export const githubCopilotLanguageModel = { + vendor: githubCopilotVendor, + /** Selector matching every model the Copilot extension contributes. */ + selector: { vendor: githubCopilotVendor }, + authProviderId: "github", + /** In Copilot Chat's own preference order, so Zoo recognizes the session Copilot actually uses. */ + authScopeSets: [["user:email"], ["read:user"]], + chatExtensionId: "GitHub.copilot-chat", + /** Public Command Palette command; VS Code offers no API to sign out, so Zoo defers to this UI. */ + manageAccountsCommandId: "workbench.action.manageAccounts", +} as const + +/** + * Baseline for a model whose capabilities are not yet known. Unlike `openAiModelInfoSaneDefaults` it + * leaves vision unset, so a capability nobody reported is not mistaken for one that is unsupported. + */ +export const vscodeLlmBaselineModelInfo: ModelInfo = { + contextWindow: openAiModelInfoSaneDefaults.contextWindow, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, +} + export type VscodeLlmModelId = keyof typeof vscodeLlmModels export const vscodeLlmDefaultModelId: VscodeLlmModelId = "claude-sonnet-4.5" diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..f83800774b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelInfo, type ModelRecord, type RouterModels } from "./model.js" import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" @@ -48,6 +48,8 @@ export interface ExtensionMessage { | typeof OllamaModelsMessageType.ollamaModels | typeof LmStudioModelsMessageType.lmStudioModels | typeof VsCodeLmModelsMessageType.vsCodeLmModels + | typeof VsCodeLmModelsMessageType.githubCopilotModels + | typeof VsCodeLmModelsMessageType.githubCopilotSignInResult | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -146,7 +148,16 @@ export interface ExtensionMessage { openAiModels?: string[] ollamaModels?: ModelRecord lmStudioModels?: ModelRecord - vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] + vsCodeLmModels?: { + vendor?: string + family?: string + version?: string + id?: string + name?: string + maxInputTokens?: number + modelInfo?: ModelInfo + }[] + githubCopilotAccount?: string | null mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ProviderSettingsEntry[] @@ -499,6 +510,9 @@ export interface WebviewMessage { | typeof LmStudioModelsMessageType.requestLmStudioModels | "requestRooModels" | typeof VsCodeLmModelsMessageType.requestVsCodeLmModels + | typeof VsCodeLmModelsMessageType.githubCopilotSignIn + | typeof VsCodeLmModelsMessageType.githubCopilotReconnect + | typeof VsCodeLmModelsMessageType.githubCopilotManageAccount | "openImage" | "saveImage" | "openFile" diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index afe3d83e07..984bab2c96 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -78,6 +78,7 @@ import { VercelAiGatewayHandler, VertexHandler, VsCodeLmHandler, + GitHubCopilotHandler, XAIHandler, ZAiHandler, ZooGatewayHandler, @@ -103,6 +104,7 @@ const expectedHandlers = { [providerIdentifiers.moonshot]: MoonshotHandler, [providerIdentifiers.kimiCode]: KimiCodeHandler, [providerIdentifiers.vscodeLm]: VsCodeLmHandler, + [providerIdentifiers.githubCopilot]: GitHubCopilotHandler, [providerIdentifiers.mistral]: MistralHandler, [providerIdentifiers.requesty]: RequestyHandler, [providerIdentifiers.unbound]: UnboundHandler, @@ -135,6 +137,10 @@ describe("buildApiHandler", () => { expect(handler).toBeInstanceOf(Handler) }) + it("keeps the generic VS Code LM provider free of Copilot-specific behavior", () => { + expect(buildApiHandler({ apiProvider: providerIdentifiers.vscodeLm })).not.toBeInstanceOf(GitHubCopilotHandler) + }) + it.each([ ["an unspecified model", undefined, VertexHandler], ["non-Claude models", "non-claude-test-model", VertexHandler], diff --git a/src/api/index.ts b/src/api/index.ts index d9413a31ba..c302367996 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -31,6 +31,7 @@ import { KimiCodeHandler, MistralHandler, VsCodeLmHandler, + GitHubCopilotHandler, RequestyHandler, UnboundHandler, FakeAIHandler, @@ -230,6 +231,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new KimiCodeHandler(options) case providerIdentifiers.vscodeLm: return new VsCodeLmHandler(options) + case providerIdentifiers.githubCopilot: + return new GitHubCopilotHandler(options) case providerIdentifiers.mistral: return new MistralHandler(options) case providerIdentifiers.requesty: diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts new file mode 100644 index 0000000000..ca202065ae --- /dev/null +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -0,0 +1,444 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import { openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types" + +// Mocks must come first, before imports +vi.mock("vscode", () => { + class MockLanguageModelTextPart { + type = "text" + constructor(public value: string) {} + } + + class MockLanguageModelToolCallPart { + type = "tool_call" + constructor( + public callId: string, + public name: string, + public input: object, + ) {} + } + + class MockLanguageModelToolResultPart { + type = "tool_result" + constructor( + public callId: string, + public content: unknown[], + ) {} + } + + return { + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string, defaultValue: unknown) => defaultValue), + })), + onDidChangeConfiguration: vi.fn((_callback) => ({ + dispose: vi.fn(), + })), + }, + CancellationTokenSource: vi.fn(function () { + return { + token: { + isCancellationRequested: false, + onCancellationRequested: vi.fn(), + }, + cancel: vi.fn(), + dispose: vi.fn(), + } + }), + CancellationError: class CancellationError extends Error { + constructor() { + super("Operation cancelled") + this.name = "CancellationError" + } + }, + LanguageModelChatMessage: { + Assistant: vi.fn((content) => ({ + role: "assistant", + content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], + })), + User: vi.fn((content) => ({ + role: "user", + content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], + })), + }, + LanguageModelTextPart: MockLanguageModelTextPart, + LanguageModelToolCallPart: MockLanguageModelToolCallPart, + LanguageModelToolResultPart: MockLanguageModelToolResultPart, + LanguageModelDataPart: class { + constructor( + public data: Uint8Array, + public mimeType: string, + ) {} + static image(data: Uint8Array, mimeType: string) { + return new this(data, mimeType) + } + }, + lm: { + selectChatModels: vi.fn(), + }, + authentication: { + getSession: vi.fn(), + }, + extensions: { + getExtension: vi.fn(), + }, + commands: { + getCommands: vi.fn(), + executeCommand: vi.fn(), + }, + } +}) + +import * as vscode from "vscode" +import { + GitHubCopilotHandler, + connectGitHubCopilot, + getGitHubCopilotAccount, + openGitHubAccountManagement, +} from "../github-copilot" +import { getVsCodeLmModelInfo } from "../vscode-lm-capabilities" +import type { ApiHandlerOptions } from "../../../shared/api" +import { clearAllMocks } from "../../../test-utils/reset" +import { collectStream } from "../../../test-utils/stream" + +/** The stable typings this extension builds against predate image parts, so reach them by name. */ +const dataPart = () => + Reflect.get(vscode, "LanguageModelDataPart") as { image?: unknown; new (...args: never[]): object } + +const mockLanguageModelChat = { + id: "test-model", + name: "Test Model", + vendor: "test-vendor", + family: "test-family", + version: "1.0", + maxInputTokens: 4096, + sendRequest: vi.fn(), + countTokens: vi.fn(), +} + +describe("connectGitHubCopilot", () => { + beforeEach(() => vi.clearAllMocks()) + + it("authenticates through VS Code and selects only Copilot models", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValue({ + id: "test-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "test-account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const onAuthenticated = vi.fn(async (account: string) => { + expect(account).toBe("Test User") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) + await expect(connectGitHubCopilot(onAuthenticated)).resolves.toEqual({ account: "Test User", models: [] }) + expect(onAuthenticated).toHaveBeenCalledOnce() + + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { createIfNone: true }) + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + }) + + it("rejects an incomplete sign-in rather than reporting a connected account", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce(undefined) + await expect(connectGitHubCopilot()).rejects.toThrow("GitHub sign-in did not complete") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) + + it("requests a fresh account session when reconnecting", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce({ + id: "fresh-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "account", label: "Test User" }, + }) + await connectGitHubCopilot(undefined, true) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { + forceNewSession: true, + clearSessionPreference: true, + }) + }) + + describe("account", () => { + const session = (label: string) => ({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label }, + }) + + it("recognizes the session Copilot itself uses, whichever supported scope set holds it", async () => { + vi.mocked(vscode.authentication.getSession).mockImplementation(async (_provider, scopes) => + (scopes as readonly string[]).includes("read:user") ? session("Legacy User") : undefined, + ) + await expect(getGitHubCopilotAccount()).resolves.toBe("Legacy User") + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { silent: true }) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["read:user"], { silent: true }) + }) + + it("stops at the first scope set that has a session", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValue(session("Current User")) + await expect(getGitHubCopilotAccount()).resolves.toBe("Current User") + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + it("reports no account rather than failing when the lookup throws", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValue(new Error("auth unavailable")) + await expect(getGitHubCopilotAccount()).resolves.toBeUndefined() + }) + + it("opens VS Code's own account management instead of signing out on its behalf", async () => { + await openGitHubAccountManagement() + expect(vscode.commands.executeCommand).toHaveBeenCalledExactlyOnceWith("workbench.action.manageAccounts") + }) + }) + + it("shares one sign-in between concurrent requests", async () => { + let finish: (value: ReturnType) => void = () => {} + const session = (label: string) => ({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label }, + }) + vi.mocked(vscode.authentication.getSession).mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) as never, + ) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const first = connectGitHubCopilot() + const second = connectGitHubCopilot() + finish(session("Test User")) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { account: "Test User", models: [] }, + { account: "Test User", models: [] }, + ]) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + it("allows a new sign-in once the previous one has settled", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("Sign-in cancelled")) + await expect(connectGitHubCopilot()).rejects.toThrow("Sign-in cancelled") + + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await expect(connectGitHubCopilot()).resolves.toEqual({ account: "Test User", models: [] }) + }) + it("propagates authentication failures without attempting model discovery", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("Sign-in cancelled")) + vi.mocked(vscode.lm.selectChatModels).mockClear() + + await expect(connectGitHubCopilot()).rejects.toThrow("Sign-in cancelled") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) +}) + +describe("Copilot model capabilities", () => { + const curatedWithVision = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) + if (!curatedWithVision) throw new Error("expected the curated catalog to contain a vision-capable model") + const [curatedFamily, curatedEntry] = curatedWithVision + + const liveModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + vendor: "copilot", + id: "synthetic-model", + family: "synthetic-family", + maxInputTokens: 16000, + ...overrides, + }) + + const imageSource = { type: "base64" as const, media_type: "image/png" as const, data: "aW1hZ2U=" } + const imageMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "image", source: imageSource }] }, + ] + const toolResultImageMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tool-1", content: [{ type: "image", source: imageSource }] }, + ], + }, + ] + + const send = async ( + model: ReturnType, + messages: Anthropic.Messages.MessageParam[], + metadata?: Parameters[2], + ) => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model]) + const handler = new GitHubCopilotHandler({}) + try { + await handler.initializeClient() + await collectStream(handler.createMessage("", messages, metadata)) + } finally { + handler.dispose() + } + } + + beforeEach(() => { + vi.clearAllMocks() + mockLanguageModelChat.countTokens.mockResolvedValue(10) + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("Done") + })(), + })) + }) + + describe("vision resolution", () => { + it.each([true, false])("treats the host-reported vision flag (%s) as authoritative", (reported) => { + const info = getVsCodeLmModelInfo(liveModel({ capabilities: { supportsImageToText: reported } })) + expect(info.supportsImages).toBe(reported) + }) + + it("accepts the provider-side capability spelling when the consumer-side one is absent", () => { + expect(getVsCodeLmModelInfo(liveModel({ capabilities: { imageInput: true } })).supportsImages).toBe(true) + }) + + it("lets an explicit host report override the curated catalog", () => { + const model = liveModel({ family: curatedFamily, capabilities: { supportsImageToText: false } }) + expect(getVsCodeLmModelInfo(model).supportsImages).toBe(false) + }) + + it("falls back to the curated catalog only when the host reports nothing", () => { + expect(getVsCodeLmModelInfo(liveModel({ family: curatedFamily })).supportsImages).toBe(true) + }) + + it("leaves vision unset rather than guessing when no source states it", () => { + expect(getVsCodeLmModelInfo(liveModel()).supportsImages).toBeUndefined() + }) + + it("reports no vision when this host cannot construct image parts", () => { + const parts = dataPart() + const original = parts.image + Object.assign(parts, { image: undefined }) + try { + expect( + getVsCodeLmModelInfo(liveModel({ capabilities: { supportsImageToText: true } })).supportsImages, + ).toBe(false) + } finally { + Object.assign(parts, { image: original }) + } + }) + + it("keeps unknown vision unset through the no-client fallback instead of defaulting it to unsupported", () => { + const handler = new GitHubCopilotHandler({ + vsCodeLmModelSelector: { vendor: "copilot", family: "synthetic-family" }, + }) + try { + expect(handler.getModel().info.supportsImages).toBeUndefined() + } finally { + handler.dispose() + } + }) + + it("does not set a tool-count limit, since consumers are only told whether tools are supported", () => { + const info = getVsCodeLmModelInfo(liveModel({ capabilities: { supportsToolCalling: true } })) + expect(info.maxTools).toBeUndefined() + }) + }) + + describe("context window", () => { + it("uses the live input limit when the catalog has no entry", () => { + expect(getVsCodeLmModelInfo(liveModel({ maxInputTokens: 921793 })).contextWindow).toBe(921793) + }) + + it("honors a live limit smaller than the curated limit", () => { + const model = liveModel({ family: curatedFamily, maxInputTokens: curatedEntry.maxInputTokens - 1 }) + expect(getVsCodeLmModelInfo(model).contextWindow).toBe(curatedEntry.maxInputTokens - 1) + }) + + it("caps an inflated live limit at the curated limit", () => { + const model = liveModel({ family: curatedFamily, maxInputTokens: curatedEntry.maxInputTokens * 10 }) + expect(getVsCodeLmModelInfo(model).contextWindow).toBe(curatedEntry.maxInputTokens) + }) + + it("falls back to a sane default when the host reports no usable limit", () => { + expect(getVsCodeLmModelInfo(liveModel({ maxInputTokens: 0 })).contextWindow).toBe( + openAiModelInfoSaneDefaults.contextWindow, + ) + }) + }) + + describe("requests", () => { + const visionModel = () => liveModel({ capabilities: { supportsImageToText: true } }) + + it("transmits image bytes and counts the real message when vision is reported", async () => { + await send(visionModel(), imageMessages) + const messages = mockLanguageModelChat.sendRequest.mock.calls[0][0] + expect(messages[1].content[0]).toBeInstanceOf(dataPart()) + expect(messages[1].content[0].data).toEqual(Buffer.from("image")) + expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith(messages[1], expect.anything()) + }) + + it("transmits image bytes nested inside tool results", async () => { + await send(visionModel(), toolResultImageMessages) + const toolResult = mockLanguageModelChat.sendRequest.mock.calls[0][0][1].content[0] + expect(toolResult.content[0]).toBeInstanceOf(dataPart()) + }) + + it.each([ + ["reported unsupported", { capabilities: { supportsImageToText: false } }], + ["not reported", {}], + ])("rejects images before sending when vision is %s", async (_label, overrides) => { + await expect(send(liveModel(overrides), imageMessages)).rejects.toThrow( + "does not report image input support", + ) + await expect(send(liveModel(overrides), toolResultImageMessages)).rejects.toThrow( + "does not report image input support", + ) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("forwards every offered tool without imposing a client-side cap", async () => { + const tools = Array.from({ length: 130 }, (_, index) => ({ + type: "function" as const, + function: { + name: `tool_${index}`, + description: "A tool", + parameters: { type: "object", properties: {} }, + }, + })) + await send(liveModel(), [], { taskId: "tool-passthrough", tools }) + expect(mockLanguageModelChat.sendRequest.mock.calls[0][1].tools).toHaveLength(130) + }) + }) +}) + +describe("GitHubCopilotHandler", () => { + const defaultOptions: ApiHandlerOptions = { + vsCodeLmModelSelector: { vendor: "test-vendor", family: "test-family" }, + } + + beforeEach(() => { + clearAllMocks() + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([{ ...mockLanguageModelChat }] as never) + }) + + it("forces the Copilot vendor even when the saved selector names another vendor", async () => { + const copilotHandler = new GitHubCopilotHandler(defaultOptions) + try { + await copilotHandler.initializeClient() + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot", family: "test-family" }) + } finally { + copilotHandler.dispose() + } + }) + + it("rejects unavailable Copilot models instead of generating placeholder output", async () => { + const copilotHandler = new GitHubCopilotHandler(defaultOptions) + try { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValueOnce([]) + await expect(copilotHandler.createClient({ vendor: "copilot", id: "missing-model" })).rejects.toThrow( + "No matching GitHub Copilot model", + ) + } finally { + copilotHandler.dispose() + } + }) +}) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index c15be285ca..429f287ab4 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -62,15 +62,35 @@ vi.mock("vscode", () => { LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, LanguageModelToolResultPart: MockLanguageModelToolResultPart, + LanguageModelDataPart: class { + constructor( + public data: Uint8Array, + public mimeType: string, + ) {} + static image(data: Uint8Array, mimeType: string) { + return new this(data, mimeType) + } + }, lm: { selectChatModels: vi.fn(), }, + authentication: { + getSession: vi.fn(), + }, + extensions: { + getExtension: vi.fn(), + }, + commands: { + getCommands: vi.fn(), + executeCommand: vi.fn(), + }, } }) import * as vscode from "vscode" import { VsCodeLmHandler, + getVsCodeLmModels, extractLeakedToolCalls, trailingPartialToolMarkerLength, middleOutTruncate, @@ -4451,3 +4471,57 @@ describe("context-window tool_result truncation", () => { }) }) }) + +describe("getVsCodeLmModels", () => { + const liveModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + id: "listed-model", + vendor: "copilot", + family: "listed-family", + ...overrides, + }) + + beforeEach(() => vi.clearAllMocks()) + + it("describes each model by identity and by the capabilities the host reports", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + liveModel({ maxInputTokens: 50000, capabilities: { supportsImageToText: true } }), + ] as never) + + const [listed] = await getVsCodeLmModels({ vendor: "copilot" }) + + expect(listed).toMatchObject({ + id: "listed-model", + vendor: "copilot", + family: "listed-family", + maxInputTokens: 50000, + modelInfo: { contextWindow: 50000, supportsImages: true }, + }) + }) + + it("passes the selector through to the host", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await getVsCodeLmModels({ vendor: "copilot" }) + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + }) + + it("omits models that can never work", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + liveModel({ id: "claude-3.7-sonnet" }), + liveModel({ id: "kept" }), + ] as never) + + expect((await getVsCodeLmModels()).map((model) => model.id)).toEqual(["kept"]) + }) + + it("reports a host that has no models as an empty list", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue(undefined as never) + await expect(getVsCodeLmModels()).resolves.toEqual([]) + }) + + it("surfaces a failed lookup instead of passing it off as 'no models'", async () => { + vi.mocked(vscode.lm.selectChatModels).mockRejectedValue(new Error("host unavailable")) + await expect(getVsCodeLmModels({ vendor: "copilot" })).rejects.toThrow("host unavailable") + await expect(getVsCodeLmModels()).rejects.toThrow("host unavailable") + }) +}) diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts new file mode 100644 index 0000000000..471bff144c --- /dev/null +++ b/src/api/providers/github-copilot.ts @@ -0,0 +1,140 @@ +import * as vscode from "vscode" +import { Anthropic } from "@anthropic-ai/sdk" + +import { type ModelInfo, githubCopilotLanguageModel, vscodeLlmBaselineModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" +import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-management/context-error-handling" + +import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format" + +import { VsCodeLmHandler, containsImageBlock, getVsCodeLmModels } from "./vscode-lm" +import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" + +/** + * GitHub Copilot as an API provider. + * + * Copilot's models are contributed to VS Code by the Copilot extension, so requests still travel + * through the VS Code Language Model API. What differs from the generic provider is that the models + * are never interchangeable with a stub, their capabilities are reported by the host, and a request + * that exceeds a model's window must fail in a way Zoo can recover from by condensing. + */ +export class GitHubCopilotHandler extends VsCodeLmHandler { + constructor(options: ApiHandlerOptions) { + super({ + ...options, + vsCodeLmModelSelector: { ...options.vsCodeLmModelSelector, vendor: githubCopilotLanguageModel.vendor }, + }) + } + + protected override createFallbackClient(): vscode.LanguageModelChat { + throw new Error( + "No matching GitHub Copilot model is available. Sign in to GitHub and enable the model in Copilot Chat.", + ) + } + + protected override deriveModelInfo(client: vscode.LanguageModelChat): ModelInfo { + return getVsCodeLmModelInfo(client) + } + + protected override getFallbackModelInfo(): ModelInfo { + return vscodeLlmBaselineModelInfo + } + + protected override supportsImageInput(info: ModelInfo): boolean { + return info.supportsImages === true + } + + protected override prepareMessageForCounting(message: vscode.LanguageModelChatMessage) { + // Image parts only count when the message reaches the counter intact. + return message + } + + protected override assertRequestSupported(messages: Anthropic.Messages.MessageParam[], info: ModelInfo): void { + if (!info.supportsImages && messages.some((message) => containsImageBlock(message.content))) { + throw new Error( + "The selected Copilot model does not report image input support. Select a model that supports images, or remove the image.", + ) + } + } + + protected override assertWithinContextWindow(inputTokens: number, info: ModelInfo): void { + if (inputTokens > info.contextWindow) { + this.ensureCleanState() + throw Object.assign( + new Error( + "The request exceeds the selected Copilot model's input context window. Condense the conversation or start a new task.", + ), + { status: CONTEXT_WINDOW_EXCEEDED_STATUS }, + ) + } + } + + /** Copilot reports each model's usable input window, so the live value is authoritative. */ + override getCondenseContextWindow(): number { + return this.getModel().info.contextWindow + } + + override async countTokens(content: Array): Promise { + await this.getClient() + const info = this.getModel().info + const messages = convertToVsCodeLmMessages([{ role: "user", content }], this.supportsImageInput(info)) + return this.calculateTotalInputTokens(messages) + } +} + +// Sign-in ----------------------------------------------------------------------------------------- + +function getGitHubSession(options: vscode.AuthenticationGetSessionOptions) { + const { authProviderId, authScopeSets } = githubCopilotLanguageModel + return authScopeSets.reduce>( + async (found, scopes) => (await found) ?? vscode.authentication.getSession(authProviderId, scopes, options), + Promise.resolve(undefined), + ) +} + +let signInInFlight: Promise<{ account: string; models: Awaited> }> | undefined + +/** + * Signs in to GitHub and lists the Copilot models that become available. Concurrent calls share one + * attempt, so a double click cannot open two sign-in prompts. + * + * Authenticating does not prove a Copilot entitlement: an account without one signs in successfully + * and simply sees no models, which callers must present as such. + */ +export function connectGitHubCopilot(onAuthenticated?: (account: string) => Promise, forceNewSession = false) { + signInInFlight ??= (async () => { + try { + const { authProviderId, authScopeSets, chatExtensionId, selector } = githubCopilotLanguageModel + const session = await vscode.authentication.getSession( + authProviderId, + authScopeSets[0], + forceNewSession ? { forceNewSession: true, clearSessionPreference: true } : { createIfNone: true }, + ) + if (!session) throw new Error("GitHub sign-in did not complete. Please try again.") + await onAuthenticated?.(session.account.label) + await vscode.extensions.getExtension(chatExtensionId)?.activate() + return { account: session.account.label, models: await getVsCodeLmModels(selector) } + } finally { + signInInFlight = undefined + } + })() + return signInInFlight +} + +/** The signed-in account, or undefined when there is none or the lookup is not possible. */ +export async function getGitHubCopilotAccount() { + try { + return (await getGitHubSession({ silent: true }))?.account.label + } catch { + return undefined + } +} + +/** + * Opens VS Code's own account management, where the user can sign out. VS Code exposes no API to + * sign out on an extension's behalf, so Zoo defers to the host's UI instead of reaching for internals. + */ +export function openGitHubAccountManagement() { + return vscode.commands.executeCommand(githubCopilotLanguageModel.manageAccountsCommandId) +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 8ba1eae382..00a719725e 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -23,6 +23,7 @@ export { SambaNovaHandler } from "./sambanova" export { UnboundHandler } from "./unbound" export { VertexHandler } from "./vertex" export { VsCodeLmHandler } from "./vscode-lm" +export { GitHubCopilotHandler } from "./github-copilot" export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" diff --git a/src/api/providers/vscode-lm-capabilities.ts b/src/api/providers/vscode-lm-capabilities.ts new file mode 100644 index 0000000000..fa3e2cf3a5 --- /dev/null +++ b/src/api/providers/vscode-lm-capabilities.ts @@ -0,0 +1,57 @@ +import * as vscode from "vscode" + +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + vscodeLlmBaselineModelInfo, + vscodeLlmModels, +} from "@roo-code/types" + +import { canCreateImageParts } from "../transform/vscode-lm-image-part" + +/** + * Vision as the host reports it. + * + * VS Code exposes this to consumers as `capabilities.supportsImageToText` (a proposed API surface, + * `vscode.proposed.languageModelCapabilities`) and to providers as `capabilities.imageInput`. Both are + * read by name because the stable typings declare neither; a host that reports neither yields `undefined` + * so the caller can tell "unsupported" apart from "unknown". + */ +export function reportedImageSupport(model: vscode.LanguageModelChat): boolean | undefined { + const capabilities: unknown = Reflect.get(model, "capabilities") + if (typeof capabilities !== "object" || capabilities === null) { + return undefined + } + for (const key of ["supportsImageToText", "imageInput"]) { + const reported: unknown = Reflect.get(capabilities, key) + if (typeof reported === "boolean") { + return reported + } + } + return undefined +} + +/** + * Derives Zoo's model info from what VS Code reports for a live model. Reported values win; the + * curated catalog only fills gaps, and anything neither source states stays unset rather than guessed. + */ +export function getVsCodeLmModelInfo(model: vscode.LanguageModelChat): ModelInfo { + const curated = vscodeLlmModels[model.family as keyof typeof vscodeLlmModels] + const liveInputLimit = + Number.isFinite(model.maxInputTokens) && model.maxInputTokens > 0 + ? model.maxInputTokens + : openAiModelInfoSaneDefaults.contextWindow + const curatedInputLimit = curated?.maxInputTokens + + return { + ...vscodeLlmBaselineModelInfo, + ...curated, + contextWindow: + curatedInputLimit && curatedInputLimit > 0 ? Math.min(liveInputLimit, curatedInputLimit) : liveInputLimit, + // Without image-part support in the host, no image can be sent whatever the model advertises. + supportsImages: canCreateImageParts() ? (reportedImageSupport(model) ?? curated?.supportsImages) : false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + } +} diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 7326d3ec8a..ebc99ab91b 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -23,6 +23,7 @@ import { sanitizeSurrogatesDeep, sanitizeToolNameSurrogates, } from "../transform/vscode-lm-format" +import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-management/context-error-handling" @@ -51,6 +52,22 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode })) } +/** Structural check, so it holds for any host that supplies chat messages rather than one specific class. */ +function isChatMessage(value: unknown): value is vscode.LanguageModelChatMessage { + return typeof value === "object" && value !== null && "role" in value && "content" in value +} + +/** True when any block, including those nested in tool results, carries an image. */ +export function containsImageBlock(content: unknown): boolean { + return ( + Array.isArray(content) && + content.some( + (block: { type?: string; content?: unknown }) => + block.type === "image" || (block.type === "tool_result" && containsImageBlock(block.content)), + ) + ) +} + /** * Handles interaction with VS Code's Language Model API for chat-based operations. * This handler extends BaseProvider to provide VS Code LM specific functionality. @@ -757,7 +774,7 @@ function extractLeakedToolCallsWithState( export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: vscode.LanguageModelChat | null + protected client: vscode.LanguageModelChat | null private disposable: vscode.Disposable | null private currentRequestCancellation: vscode.CancellationTokenSource | null @@ -780,7 +797,12 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } }) - this.initializeClient() + void this.initializeClient().catch((error) => { + console.debug( + "Zoo Code : Initial client setup failed; retrying on first use:", + error, + ) + }) } catch (error) { // Ensure cleanup if constructor fails this.dispose() @@ -834,35 +856,78 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return models[0] } - // Create a minimal model if no models are available - return { - id: "default-lm", - name: "Default Language Model", - vendor: "vscode", - family: "lm", - version: "1.0", - maxInputTokens: 8192, - sendRequest: async (_messages, _options, _token) => { - // Provide a minimal implementation - return { - stream: (async function* () { - yield new vscode.LanguageModelTextPart( - "Language model functionality is limited. Please check VS Code configuration.", - ) - })(), - text: (async function* () { - yield "Language model functionality is limited. Please check VS Code configuration." - })(), - } - }, - countTokens: async () => 0, - } + return this.createFallbackClient() } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" throw new Error(`Zoo Code : Failed to select model: ${errorMessage}`) } } + /** + * Client used when no installed model matches the selector. Subclasses whose models are not + * interchangeable with a stub may refuse instead. + */ + protected createFallbackClient(): vscode.LanguageModelChat { + return { + id: "default-lm", + name: "Default Language Model", + vendor: "vscode", + family: "lm", + version: "1.0", + maxInputTokens: 8192, + sendRequest: async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart( + "Language model functionality is limited. Please check VS Code configuration.", + ) + })(), + text: (async function* () { + yield "Language model functionality is limited. Please check VS Code configuration." + })(), + }), + countTokens: async () => 0, + } + } + + /** Model info for a live client. */ + protected deriveModelInfo(client: vscode.LanguageModelChat, modelId: string): ModelInfo { + return { + maxTokens: -1, // Unlimited tokens by default + contextWindow: + typeof client.maxInputTokens === "number" + ? Math.max(0, client.maxInputTokens) + : openAiModelInfoSaneDefaults.contextWindow, + supportsImages: false, // VSCode Language Model API historically had no image input + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + description: `VSCode Language Model: ${modelId}`, + } + } + + /** Model info reported before any client exists. */ + protected getFallbackModelInfo(): ModelInfo { + return openAiModelInfoSaneDefaults + } + + /** Whether image parts are sent to the model. */ + protected supportsImageInput(_info: ModelInfo): boolean { + return false + } + + /** Rejects a request the selected model cannot serve before anything is sent. */ + protected assertRequestSupported(_messages: Anthropic.Messages.MessageParam[], _info: ModelInfo): void {} + + /** Rejects a request whose measured input exceeds what the model accepts. */ + protected assertWithinContextWindow(_inputTokens: number, _info: ModelInfo): void {} + + /** The form in which a chat message is handed to the host's token counter. */ + protected prepareMessageForCounting( + message: vscode.LanguageModelChatMessage, + ): string | vscode.LanguageModelChatMessage { + return extractTextCountFromMessage(message) + } + /** * Creates and streams a message using the VS Code Language Model API. * @@ -947,13 +1012,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan if (typeof text === "string") { tokenCount = await this.client.countTokens(text, cancellationToken) - } else if (text instanceof vscode.LanguageModelChatMessage) { + } else if (isChatMessage(text)) { // For chat messages, ensure we have content if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) { console.debug("Zoo Code : Empty chat message content") return 0 } - const countMessage = extractTextCountFromMessage(text) + const countMessage = this.prepareMessageForCounting(text) tokenCount = await this.client.countTokens(countMessage, cancellationToken) } else { console.warn("Zoo Code : Invalid input type for token counting") @@ -996,13 +1061,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { + protected async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { const messageTokens: number[] = await Promise.all(vsCodeLmMessages.map((msg) => this.internalCountTokens(msg))) return messageTokens.reduce((sum: number, tokens: number): number => sum + tokens, 0) } - private ensureCleanState(): void { + protected ensureCleanState(): void { if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel() this.currentRequestCancellation.dispose() @@ -1010,7 +1075,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - private async getClient(): Promise { + protected async getClient(): Promise { if (!this.client) { console.debug("Zoo Code : Getting client with options:", { vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, @@ -1071,12 +1136,14 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Ensure clean state before starting a new request this.ensureCleanState() const client: vscode.LanguageModelChat = await this.getClient() + const modelInfo = this.getModel().info // Process messages const cleanedMessages = messages.map((msg) => ({ ...msg, content: this.cleanMessageContent(msg.content), })) + this.assertRequestSupported(cleanedMessages, modelInfo) // Keep context-window trimming on OUR side. Copilot's backend trims an over-window request // without preserving tool_use/tool_result pairing, which orphans a tool_result and triggers a @@ -1120,7 +1187,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(sanitizeSurrogates(systemPrompt)), - ...convertToVsCodeLmMessages(cleanedMessages), + ...convertToVsCodeLmMessages(cleanedMessages, this.supportsImageInput(modelInfo)), ] // Initialize cancellation token for the request @@ -1128,6 +1195,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Calculate input tokens before starting the stream const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) + this.assertWithinContextWindow(totalInputTokens, modelInfo) // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" @@ -1218,7 +1286,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, tools: convertToVsCodeLmTools(metadata?.tools ?? []), } - const response: vscode.LanguageModelChatResponse = await client.sendRequest( vsCodeLmMessages, requestOptions, @@ -1391,22 +1458,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan const modelParts = [this.client.vendor, this.client.family, this.client.version].filter(Boolean) const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR) - - // Build model info with conservative defaults for missing values - const modelInfo: ModelInfo = { - maxTokens: -1, // Unlimited tokens by default - contextWindow: - typeof this.client.maxInputTokens === "number" - ? Math.max(0, this.client.maxInputTokens) - : openAiModelInfoSaneDefaults.contextWindow, - supportsImages: false, // VSCode Language Model API currently doesn't support image inputs - supportsPromptCache: true, - inputPrice: 0, - outputPrice: 0, - description: `VSCode Language Model: ${modelId}`, - } - - return { id: modelId, info: modelInfo } + return { id: modelId, info: this.deriveModelInfo(this.client, modelId) } } // Fallback when no client is available @@ -1419,7 +1471,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return { id: fallbackId, info: { - ...openAiModelInfoSaneDefaults, + ...this.getFallbackModelInfo(), description: `VSCode Language Model (Fallback): ${fallbackId}`, }, } @@ -1472,14 +1524,21 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] -export async function getVsCodeLmModels() { - try { - const models = (await vscode.lm.selectChatModels({})) || [] - return models.filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) - } catch (error) { - console.error( - `Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, - ) - return [] - } +/** + * Lists the language models the host currently offers. A failure is surfaced to the caller rather + * than reported as an empty list: "no models" and "could not ask" call for different handling. + */ +export async function getVsCodeLmModels(selector: vscode.LanguageModelChatSelector = {}) { + const models = (await vscode.lm.selectChatModels(selector)) || [] + return models + .filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + .map((model) => ({ + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + name: model.name, + maxInputTokens: model.maxInputTokens, + modelInfo: getVsCodeLmModelInfo(model), + })) } diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index f55bf0356a..368cf8f123 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -93,10 +93,80 @@ vitest.mock("vscode", () => { LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, LanguageModelToolResultPart: MockLanguageModelToolResultPart, + // In VS Code this is a class whose static `image` builds the part. + LanguageModelDataPart: class { + static image = vitest.fn((data: Uint8Array, mimeType: string) => ({ data, mimeType })) + }, } }) describe("convertToVsCodeLmMessages", () => { + it("sends decoded image bytes when image input is enabled", () => { + const result = convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + }, + ], + }, + ], + true, + ) + expect(Reflect.get(Reflect.get(vscode, "LanguageModelDataPart"), "image")).toHaveBeenCalledWith( + Buffer.from("image"), + "image/png", + ) + expect(result[0].content[0]).toEqual({ data: Buffer.from("image"), mimeType: "image/png" }) + }) + + it("preserves image bytes within tool results", () => { + const result = convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "image-tool", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/jpeg", data: "aW1hZ2U=" }, + }, + ], + }, + ], + }, + ], + true, + ) + const part = result[0].content[0] as vscode.LanguageModelToolResultPart + expect(part.content[0]).toEqual({ data: Buffer.from("image"), mimeType: "image/jpeg" }) + }) + + it("does not silently replace unsupported image URLs when image input is enabled", () => { + expect(() => + convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "url", url: "https://example.com/image.png" }, + }, + ], + }, + ], + true, + ), + ).toThrow("requires base64 image data") + }) + it("should convert simple string messages", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello" }, diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index c20a108788..8e56d7a60f 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" +import { createImagePart, createUserMessage, type ImagePart } from "./vscode-lm-image-part" import { HAS_LONE_SURROGATE, LONE_SURROGATE, @@ -92,8 +93,27 @@ export function decodeToolNameSurrogates(name: string): string { return decoded } +function convertImageToVsCodeLmPart(image: Anthropic.Messages.ImageBlockParam, supportsImages: boolean) { + if (supportsImages) { + if (image.source.type !== "base64") { + throw new Error("GitHub Copilot image input requires base64 image data, not an image URL.") + } + const part = createImagePart(Buffer.from(image.source.data, "base64"), image.source.media_type) + if (!part) { + throw new Error("Image input requires a newer version of VS Code that can send image data.") + } + return part + } + return new vscode.LanguageModelTextPart( + image.source.type === "base64" + ? `[Image (base64): ${image.source.media_type} not supported by VSCode LM API]` + : `[Image (${image.source.type}): not supported by VSCode LM API]`, + ) +} + export function convertToVsCodeLmMessages( anthropicMessages: Anthropic.Messages.MessageParam[], + supportsImages = false, ): vscode.LanguageModelChatMessage[] { const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [] @@ -132,19 +152,12 @@ export function convertToVsCodeLmMessages( // Convert tool messages to ToolResultParts ...toolMessages.map((toolMessage) => { // Process tool result content into TextParts - const toolContentParts: vscode.LanguageModelTextPart[] = + const toolContentParts: Array = typeof toolMessage.content === "string" ? [new vscode.LanguageModelTextPart(sanitizeSurrogates(toolMessage.content))] : (toolMessage.content?.map((part) => { if (part.type === "image") { - if (part.source.type === "base64") { - return new vscode.LanguageModelTextPart( - `[Image (base64): ${part.source.media_type} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart( - `[Image (${part.source.type}): not supported by VSCode LM API]`, - ) + return convertImageToVsCodeLmPart(part, supportsImages) } if (part.type === "text") { return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) @@ -161,21 +174,14 @@ export function convertToVsCodeLmMessages( // Convert non-tool messages to TextParts after tool messages ...nonToolMessages.map((part) => { if (part.type === "image") { - if (part.source.type === "base64") { - return new vscode.LanguageModelTextPart( - `[Image (base64): ${part.source.media_type} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart( - `[Image (${part.source.type}): not supported by VSCode LM API]`, - ) + return convertImageToVsCodeLmPart(part, supportsImages) } return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), ] // Add single user message with all content parts - vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts)) + vsCodeLmMessages.push(createUserMessage(contentParts)) break } diff --git a/src/api/transform/vscode-lm-image-part.ts b/src/api/transform/vscode-lm-image-part.ts new file mode 100644 index 0000000000..b831255a2d --- /dev/null +++ b/src/api/transform/vscode-lm-image-part.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode" + +/** Shape of `vscode.LanguageModelDataPart`, whose typings postdate our minimum VS Code version. */ +export interface ImagePart { + readonly mimeType: string + readonly data: Uint8Array +} + +export type UserMessagePart = vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart | ImagePart + +type ImagePartFactory = (data: Uint8Array, mimeType: string) => ImagePart + +/** Resolved per call because the host, not this bundle, decides whether image parts exist. */ +function resolveImagePartFactory(): ImagePartFactory | undefined { + const dataPart: unknown = Reflect.get(vscode, "LanguageModelDataPart") + const image: unknown = typeof dataPart === "function" ? Reflect.get(dataPart, "image") : undefined + return typeof image === "function" + ? (data, mimeType) => Reflect.apply(image, dataPart, [data, mimeType]) + : undefined +} + +export function canCreateImageParts(): boolean { + return resolveImagePartFactory() !== undefined +} + +/** Returns undefined on hosts that cannot carry image data. */ +export function createImagePart(data: Uint8Array, mimeType: string): ImagePart | undefined { + return resolveImagePartFactory()?.(data, mimeType) +} + +export function createUserMessage(parts: UserMessagePart[]): vscode.LanguageModelChatMessage { + // Hosts that support image parts accept them as content; the older typings just do not declare them. + return vscode.LanguageModelChatMessage.User( + parts as Exclude[0], string>, + ) +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fdcc740998..d0e6febe5c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -54,7 +54,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, - MAX_MCP_TOOLS_THRESHOLD, + getMcpToolThreshold, countEnabledMcpTools, providerIdentifiers, } from "@roo-code/types" @@ -2974,13 +2974,14 @@ export class Task extends EventEmitter implements TaskLike { // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() - if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { + const threshold = getMcpToolThreshold(this.api.getModel().info) + if (enabledToolCount > threshold) { await this.say( "too_many_tools_warning", JSON.stringify({ toolCount: enabledToolCount, serverCount: enabledServerCount, - threshold: MAX_MCP_TOOLS_THRESHOLD, + threshold, }), undefined, undefined, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 85325b39ff..ab545b223d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -11,6 +11,7 @@ import type { Mock } from "vitest" import { providerIdentifiers, + MAX_MCP_TOOLS_THRESHOLD, RooCodeEventName, type ClineMessage, type GlobalState, @@ -6460,6 +6461,45 @@ describe("Cline", () => { }) }) + describe("startTask MCP tool warning", () => { + const startWith = async (enabledToolCount: number, maxTools?: number) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "new task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ + enabledToolCount, + enabledServerCount: 1, + }) + vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "test-model", + info: { contextWindow: 1000, supportsPromptCache: false, ...(maxTools ? { maxTools } : {}) }, + }) + await taskAccess.startTask("new task") + return saySpy.mock.calls.find(([type]) => type === "too_many_tools_warning") + } + + it("keeps the advisory warning for providers that report no tool limit", async () => { + const warning = await startWith(MAX_MCP_TOOLS_THRESHOLD + 1) + expect(JSON.parse(warning![1] as string)).toMatchObject({ threshold: MAX_MCP_TOOLS_THRESHOLD }) + }) + + it("does not warn at or below the advisory threshold", async () => { + expect(await startWith(MAX_MCP_TOOLS_THRESHOLD)).toBeUndefined() + }) + + it("lets a provider-reported limit replace the advisory in both directions", async () => { + expect(await startWith(130, 200)).toBeUndefined() + const warning = await startWith(21, 20) + expect(JSON.parse(warning![1] as string)).toMatchObject({ threshold: 20 }) + }) + }) + describe("startTask", () => { it("posts a clean state immediately before adding the first task message", async () => { const task = new Task({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index cdd0975c5d..6ed51df84e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -346,7 +346,6 @@ export class ClineProvider ) ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index c57e9f3ac3..f903859657 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -78,6 +78,8 @@ vi.mock("@roo-code/telemetry", () => ({ })) import type { ModelRecord } from "@roo-code/types" +import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" +import * as copilotProvider from "../../../api/providers/github-copilot" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -133,6 +135,100 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - Copilot authentication", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: {} }) + }) + afterEach(() => vi.restoreAllMocks()) + + it("reports the account before models finish loading without exposing a token", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( + async (onAuthenticated?: (account: string) => Promise) => { + await onAuthenticated?.("Test User") + return { account: "Test User", models: [] } + }, + ) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenNthCalledWith(1, { + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { + type: "githubCopilotSignInResult", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + expect(JSON.stringify(vi.mocked(mockClineProvider.postMessageToWebview).mock.calls)).not.toContain( + "accessToken", + ) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + }) + + it("returns sign-in errors to the requesting control", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockRejectedValue(new Error("Sign-in cancelled")) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + error: "Sign-in cancelled", + }) + }) + + it("uses a fresh session for the reconnect command", async () => { + const connect = vi + .spyOn(copilotProvider, "connectGitHubCopilot") + .mockResolvedValue({ account: "New User", models: [] }) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + expect(connect).toHaveBeenCalledWith(expect.any(Function), true) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + }) + }) + + it("opens VS Code's account management and never signs out itself", async () => { + const open = vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockResolvedValue(undefined) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(open).toHaveBeenCalledOnce() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("logs, rather than surfacing, a failure to open account management", async () => { + vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockRejectedValue(new Error("command missing")) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("command missing")) + }) + + it("restores a signed-in account during passive model refresh", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(vsCodeLmProvider.getVsCodeLmModels).toHaveBeenCalledWith({ vendor: "copilot" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + }) + + it("shows model discovery errors instead of silently returning an empty list", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue(new Error("Model access denied")) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + error: "Model access denied", + }) + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 13af5fc731..c06ac792db 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,7 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + githubCopilotLanguageModel, retiredProviderIdentifiers, LmStudioModelsMessageType, OllamaModelsMessageType, @@ -80,6 +81,11 @@ import { searchCommits } from "../../utils/git" import { exportSettings, importSettingsWithFeedback } from "../config/importExport" import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" +import { + connectGitHubCopilot, + getGitHubCopilotAccount, + openGitHubAccountManagement, +} from "../../api/providers/github-copilot" import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -1508,11 +1514,57 @@ export const webviewMessageHandler = async ( } break - case VsCodeLmModelsMessageType.requestVsCodeLmModels: - const vsCodeLmModels = await getVsCodeLmModels() - // TODO: Cache like we do for OpenRouter, etc? - await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) + case VsCodeLmModelsMessageType.githubCopilotSignIn: + case VsCodeLmModelsMessageType.githubCopilotReconnect: + try { + const { models, account } = await connectGitHubCopilot(async (githubCopilotAccount: string) => { + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount, + }) + }, message.type === VsCodeLmModelsMessageType.githubCopilotReconnect) + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + vsCodeLmModels: models, + githubCopilotAccount: account, + }) + } catch (error) { + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + error: error instanceof Error ? error.message : String(error), + }) + } break + case VsCodeLmModelsMessageType.githubCopilotManageAccount: + try { + await openGitHubAccountManagement() + } catch (error) { + provider.log( + `Could not open VS Code account management: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + case VsCodeLmModelsMessageType.requestVsCodeLmModels: { + const isCopilot = message.apiConfiguration?.apiProvider === providerIdentifiers.githubCopilot + const type = isCopilot + ? VsCodeLmModelsMessageType.githubCopilotModels + : VsCodeLmModelsMessageType.vsCodeLmModels + try { + const githubCopilotAccount = isCopilot ? await getGitHubCopilotAccount() : undefined + const vsCodeLmModels = await getVsCodeLmModels(isCopilot ? githubCopilotLanguageModel.selector : {}) + await provider.postMessageToWebview({ + type, + vsCodeLmModels, + ...(isCopilot ? { githubCopilotAccount: githubCopilotAccount ?? null } : {}), + }) + } catch (error) { + await provider.postMessageToWebview({ + type, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } case "openImage": await openImage(message.text!, { values: message.values }) break diff --git a/src/extension.ts b/src/extension.ts index 99f399c687..6ce42aacd5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" +import { registerCopilotModelBroadcast } from "./services/language-models/copilotModelBroadcast" import { ClineProviderFactory } from "./core/webview/ClineProviderFactory" import { WebviewFocusTracker } from "./core/webview/WebviewFocusTracker" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" @@ -199,6 +200,13 @@ export async function activate(context: vscode.ExtensionContext) { }), ) + context.subscriptions.push( + registerCopilotModelBroadcast({ + getTargets: () => ClineProvider.getAllInstances(), + log: (message) => outputChannel.appendLine(message), + }), + ) + const codeIndexScope = new CodeIndexScope(context) context.subscriptions.push(codeIndexScope) try { diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts new file mode 100644 index 0000000000..2698996bdc --- /dev/null +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -0,0 +1,166 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" + +import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" + +import { registerCopilotModelBroadcast, type CopilotModelBroadcastTarget } from "../copilotModelBroadcast" + +vi.mock("vscode", () => ({ lm: {} })) +vi.mock("../../../api/providers/vscode-lm", () => ({ getVsCodeLmModels: vi.fn() })) + +type Models = Awaited> + +const model = (id: string) => ({ id, vendor: "copilot", family: id, version: "1", name: id, maxInputTokens: 1 }) + +function setup(overrides: { targets?: CopilotModelBroadcastTarget[]; fetchModels?: ReturnType } = {}) { + let notify: () => void = () => {} + const subscription = { dispose: vi.fn() } + const target = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const targets = overrides.targets ?? [target] + const fetchModels = overrides.fetchModels ?? vi.fn().mockResolvedValue([model("a")] as Models) + const log = vi.fn() + const registration = registerCopilotModelBroadcast({ + getTargets: () => targets, + log, + debounceMs: 100, + subscribe: (listener) => { + notify = listener + return subscription as never + }, + fetchModels: fetchModels as never, + }) + return { notify: () => notify(), registration, subscription, target, targets, fetchModels, log } +} + +describe("registerCopilotModelBroadcast", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it("looks models up once for the Copilot vendor and posts them to every view", async () => { + const second = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const first = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const { notify, fetchModels } = setup({ targets: [first, second] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(fetchModels).toHaveBeenCalledTimes(1) + expect(fetchModels).toHaveBeenCalledWith(githubCopilotLanguageModel.selector) + for (const view of [first, second]) { + expect(view.postMessageToWebview).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [model("a")], + }) + } + }) + + it("collapses a burst of change events into one refresh", async () => { + const { notify, fetchModels } = setup() + + notify() + notify() + await vi.advanceTimersByTimeAsync(50) + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(fetchModels).toHaveBeenCalledTimes(1) + }) + + it("does nothing before the debounce interval has elapsed", async () => { + const { notify, fetchModels } = setup() + + notify() + await vi.advanceTimersByTimeAsync(99) + + expect(fetchModels).not.toHaveBeenCalled() + }) + + it("includes views that open after registration", async () => { + const targets: CopilotModelBroadcastTarget[] = [] + const { notify } = setup({ targets }) + const late = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + targets.push(late) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(late.postMessageToWebview).toHaveBeenCalledTimes(1) + }) + + it("logs a failed lookup and posts nothing, so views keep their last known list", async () => { + const { notify, target, log } = setup({ fetchModels: vi.fn().mockRejectedValue(new Error("host busy")) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: host busy") + }) + + it("posts an empty list when the host genuinely reports no models", async () => { + const { notify, target } = setup({ fetchModels: vi.fn().mockResolvedValue([]) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [], + }) + }) + + it("discards a lookup that a newer change has superseded", async () => { + let resolveFirst: (models: Models) => void = () => {} + const fetchModels = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValueOnce([model("fresh")] as Models) + const { notify, target } = setup({ fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + notify() + await vi.advanceTimersByTimeAsync(100) + resolveFirst([model("stale")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(target.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(target.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ vsCodeLmModels: [model("fresh")] }), + ) + }) + + it("stops listening and cancels a pending refresh when disposed", async () => { + const { notify, registration, subscription, fetchModels } = setup() + + notify() + registration.dispose() + await vi.advanceTimersByTimeAsync(500) + + expect(subscription.dispose).toHaveBeenCalledTimes(1) + expect(fetchModels).not.toHaveBeenCalled() + }) + + it("does not post a lookup that finishes after disposal", async () => { + let resolve: (models: Models) => void = () => {} + const fetchModels = vi.fn().mockImplementation(() => new Promise((r) => (resolve = r))) + const { notify, registration, target } = setup({ fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + registration.dispose() + resolve([model("a")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(target.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("tolerates a host that has no model-change event", () => { + const registration = registerCopilotModelBroadcast({ + getTargets: () => [], + log: vi.fn(), + subscribe: () => undefined, + }) + expect(() => registration.dispose()).not.toThrow() + }) +}) diff --git a/src/services/language-models/copilotModelBroadcast.ts b/src/services/language-models/copilotModelBroadcast.ts new file mode 100644 index 0000000000..90fbcf5388 --- /dev/null +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -0,0 +1,78 @@ +import * as vscode from "vscode" + +import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" + +import { getVsCodeLmModels } from "../../api/providers/vscode-lm" + +export interface CopilotModelBroadcastTarget { + postMessageToWebview(message: { + type: typeof VsCodeLmModelsMessageType.githubCopilotModels + vsCodeLmModels: Awaited> + }): Promise +} + +export interface CopilotModelBroadcastOptions { + /** The webviews to keep current; resolved on every broadcast so views opened later are included. */ + getTargets: () => readonly CopilotModelBroadcastTarget[] + log: (message: string) => void + /** Collapses the burst of events VS Code raises while models register into one refresh. */ + debounceMs?: number + subscribe?: (listener: () => void) => vscode.Disposable | undefined + fetchModels?: typeof getVsCodeLmModels +} + +const DEFAULT_DEBOUNCE_MS = 250 + +/** + * Keeps every open webview's Copilot model list current as the host registers or removes models + * (sign-in, sign-out, entitlement changes). + * + * One subscription serves all views, so a change costs one lookup however many views exist. A failed + * lookup is logged and leaves each view's last known list in place: reporting failure as "no models" + * would wrongly present a transient error as a lost entitlement. + */ +export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOptions): vscode.Disposable { + const { + getTargets, + log, + debounceMs = DEFAULT_DEBOUNCE_MS, + subscribe = (listener) => ("lm" in vscode ? vscode.lm.onDidChangeChatModels?.(listener) : undefined), + fetchModels = getVsCodeLmModels, + } = options + + let timer: ReturnType | undefined + let latestRequest = 0 + let disposed = false + + const refresh = async () => { + const request = ++latestRequest + try { + const vsCodeLmModels = await fetchModels(githubCopilotLanguageModel.selector) + // A newer change superseded this lookup, or the extension is shutting down. + if (disposed || request !== latestRequest) return + await Promise.all( + getTargets().map((target) => + target.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels, + }), + ), + ) + } catch (error) { + log(`Failed to refresh GitHub Copilot models: ${error instanceof Error ? error.message : String(error)}`) + } + } + + const subscription = subscribe(() => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => void refresh(), debounceMs) + }) + + return { + dispose() { + disposed = true + if (timer) clearTimeout(timer) + subscription?.dispose() + }, + } +} diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 923582bd06..e44fbbd2f5 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -70,6 +70,7 @@ export class ProfileValidator { case providerIdentifiers.lmstudio: return profile.lmStudioModelId case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: // We probably need something more flexible for this one, if we need to really support it here. return profile.vsCodeLmModelSelector?.id case providerIdentifiers.openrouter: diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 865fa5cf51..e999d3890b 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -296,6 +296,21 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) + it.each([ + ["allows an allow-listed model", "copilot-gpt-3.5", true], + ["blocks a model outside the allow-list", "copilot-other", false], + ])("enforces the organization allow-list for the GitHub Copilot provider: %s", (_label, id, allowed) => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.githubCopilot]: { allowAll: false, models: ["copilot-gpt-3.5"] } }, + } + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id }, + } + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(allowed) + }) + it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/webview-ui/src/components/chat/TooManyToolsWarning.tsx b/webview-ui/src/components/chat/TooManyToolsWarning.tsx index 697fad19ae..07302c4c0f 100644 --- a/webview-ui/src/components/chat/TooManyToolsWarning.tsx +++ b/webview-ui/src/components/chat/TooManyToolsWarning.tsx @@ -4,11 +4,10 @@ import { useTooManyTools } from "@src/hooks/useTooManyTools" import WarningRow from "./WarningRow" /** - * Displays a warning when the user has too many MCP tools enabled. - * LLMs get confused when offered too many tools, which can lead to errors. + * Displays a warning when enabled MCP tools exceed the model's advertised limit. * * The warning is shown when: - * - The total number of enabled tools across all enabled MCP servers exceeds the threshold + * - The model advertises a limit and the enabled tool count exceeds it * * @example * diff --git a/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx b/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx index 85560201da..456d38ade6 100644 --- a/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx @@ -12,10 +12,16 @@ vi.mock("@/utils/vscode", () => ({ // Mock ExtensionState context with variable mcpServers const mockMcpServers = vi.fn() +const mockModelInfo = vi.fn() + +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: mockModelInfo() }), +})) vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ mcpServers: mockMcpServers(), + apiConfiguration: {}, }), })) @@ -52,6 +58,36 @@ describe("TooManyToolsWarning", () => { beforeEach(() => { vi.clearAllMocks() mockMcpServers.mockReturnValue([]) + mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false }) + }) + + const serverWithTools = (count: number) => [ + { + name: "server1", + status: "connected", + disabled: false, + tools: Array.from({ length: count }, (_, i) => ({ name: `tool${i}`, enabledForPrompt: true })), + }, + ] + + it("still warns above the default advisory when the model reports no limit", () => { + mockMcpServers.mockReturnValue(serverWithTools(MAX_MCP_TOOLS_THRESHOLD + 1)) + render() + expect(screen.getByText("Too many tools enabled")).toBeInTheDocument() + }) + + it("uses a provider-supplied limit instead of the default advisory", () => { + mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false, maxTools: 200 }) + mockMcpServers.mockReturnValue(serverWithTools(130)) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("warns once a provider-supplied limit is exceeded and reports that limit", () => { + mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false, maxTools: 20 }) + mockMcpServers.mockReturnValue(serverWithTools(21)) + render() + expect(screen.getByText(/Try to keep it below 20\./)).toBeInTheDocument() }) it("does not render when there are no MCP servers", () => { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2c80635bd5..b08120e54b 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -246,8 +246,17 @@ const ApiOptions = ({ }) } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) - } else if (selectedProvider === providerIdentifiers.vscodeLm) { - vscode.postMessage({ type: VsCodeLmModelsMessageType.requestVsCodeLmModels }) + } else if ( + selectedProvider === providerIdentifiers.vscodeLm || + selectedProvider === providerIdentifiers.githubCopilot + ) { + vscode.postMessage({ + type: VsCodeLmModelsMessageType.requestVsCodeLmModels, + // Only Copilot needs scoping; the legacy provider keeps its original unscoped request. + ...(selectedProvider === providerIdentifiers.githubCopilot && { + apiConfiguration: { apiProvider: selectedProvider }, + }), + }) } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, @@ -624,8 +633,10 @@ const ApiOptions = ({ )} - {selectedProvider === providerIdentifiers.vscodeLm && ( + {(selectedProvider === providerIdentifiers.vscodeLm || + selectedProvider === providerIdentifiers.githubCopilot) && ( diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 34feecb2bb..c0c6b9fdbf 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -6,6 +6,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { formatPrice } from "@src/utils/formatPrice" import { cn } from "@src/lib/utils" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { StandardTooltip } from "@src/components/ui" import { ModelDescriptionMarkdown } from "./ModelDescriptionMarkdown" @@ -69,16 +70,34 @@ export const ModelInfoView = ({ {modelInfo.maxTokens?.toLocaleString()} tokens ), - , - , + apiProvider === providerIdentifiers.githubCopilot && modelInfo?.supportsImages === undefined ? ( +
+
+ ) : ( + + ), + apiProvider === providerIdentifiers.githubCopilot ? ( + +
+
+
+ ) : ( + + ), apiProvider === providerIdentifiers.gemini && ( {selectedModelId.includes("pro-preview") diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index 58e23511a7..48ed8b4a8b 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -130,6 +130,15 @@ describe("ApiOptions Provider Filtering", () => { expect(useRouterModels).toHaveBeenCalledWith({ enabled: false, provider: undefined }) }) + it("shows GitHub Copilot in the provider dropdown", () => { + vi.mocked(useExtensionState).mockReturnValue({ + ...useExtensionState(), + organizationAllowList: { allowAll: true, providers: {} }, + }) + renderWithProviders() + expect(screen.getByTestId(`option-${providerIdentifiers.githubCopilot}`)).toHaveTextContent("GitHub Copilot") + }) + it("requests router models only for the selected dynamic provider", () => { vi.mocked(useSelectedModel).mockReturnValue({ provider: providerIdentifiers.kenari, diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx index 1e66b03255..cc20e1eb34 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -8,6 +8,11 @@ vi.mock("@src/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ t: (key: string) => ({ + "settings:modelInfo.imageSupportUnknown": "Image support not reported", + "settings:modelInfo.supportsImages": "Supports images", + "settings:modelInfo.noImages": "Does not support images", + "settings:modelInfo.noPromptCache": "Does not support prompt caching", + "settings:modelInfo.promptCacheManagedByCopilot": "Prompt caching managed by GitHub Copilot", "settings:serviceTier.pricingTableTitle": "Service tier pricing", "settings:serviceTier.columns.tier": "Tier", "settings:serviceTier.columns.input": "Input", @@ -43,6 +48,61 @@ const getPricingRowValues = (tier: string) => { } describe("ModelInfoView service tier pricing", () => { + it("shows unknown Copilot image capability without claiming the model is text-only", () => { + render( + , + ) + expect(screen.getByText("Image support not reported")).toBeInTheDocument() + expect(screen.queryByText("Does not support images")).not.toBeInTheDocument() + expect(screen.queryByText("Supports images")).not.toBeInTheDocument() + }) + + it("continues to show explicit Copilot image restrictions as unsupported", () => { + render( + , + ) + expect(screen.getByText("Does not support images")).toBeInTheDocument() + expect(screen.queryByText("Image support not reported")).not.toBeInTheDocument() + }) + + it("shows vision and provider-managed caching for Copilot without claiming caching is unsupported", () => { + render( + , + ) + expect(screen.getByText("Supports images")).toBeInTheDocument() + expect(screen.getByText("Prompt caching managed by GitHub Copilot")).toBeInTheDocument() + expect(screen.getByText("Prompt caching managed by GitHub Copilot")).toHaveAttribute("tabindex", "0") + expect(screen.queryByText("Does not support prompt caching")).not.toBeInTheDocument() + }) + + it("keeps unsupported caching labels for providers whose models do not support caching", () => { + render( + , + ) + expect(screen.getByText("Does not support images")).toBeInTheDocument() + expect(screen.getByText("Does not support prompt caching")).toBeInTheDocument() + }) it("uses the canonical gemini provider identifier", () => { expect(providerIdentifiers.gemini).toBe("gemini") }) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 8c51aca2fe..468010ef6b 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -57,6 +57,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.vertex, label: "GCP Vertex AI", proxy: false }, { value: providerIdentifiers.bedrock, label: "Amazon Bedrock", proxy: false }, { value: providerIdentifiers.vscodeLm, label: "VS Code LM API", proxy: false }, + { value: providerIdentifiers.githubCopilot, label: "GitHub Copilot", proxy: false }, { value: providerIdentifiers.mistral, label: "Mistral", proxy: false }, { value: providerIdentifiers.lmstudio, label: "LM Studio", proxy: true }, { value: providerIdentifiers.ollama, label: "Ollama", proxy: true }, diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index 4b8d66d165..f44008890d 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -1,40 +1,74 @@ import { useState, useCallback, useMemo } from "react" import { useEvent } from "react-use" import { LanguageModelChatSelector } from "vscode" +import { LoaderCircle, LogIn, RefreshCw, UserCog } from "lucide-react" import { type ProviderSettings, type ExtensionMessage, type ModelInfo, VsCodeLmModelsMessageType, + providerIdentifiers, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button, StandardTooltip } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" +import { useGitHubCopilotModels } from "@src/components/ui/hooks/useGitHubCopilotModels" import { ModelPicker } from "../ModelPicker" type VSCodeLMProps = { apiConfiguration: ProviderSettings - setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + setApiConfigurationField: ( + field: keyof ProviderSettings, + value: ProviderSettings[keyof ProviderSettings], + isUserAction?: boolean, + ) => void } +// A stable empty list: a fresh `[]` each render would defeat the memoization below. +const NO_MODELS: NonNullable = [] + export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeLMProps) => { const { t } = useAppTranslation() + const isCopilot = apiConfiguration.apiProvider === providerIdentifiers.githubCopilot - const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + // The legacy provider is fed by messages into local state; Copilot's list is the shared, extension-pushed one. + const [legacyModels, setLegacyModels] = useState>([]) + const copilotModels = useGitHubCopilotModels(isCopilot) + const vsCodeLmModels = (isCopilot ? copilotModels.data : legacyModels) ?? NO_MODELS + const [isConnecting, setIsConnecting] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState() + const [account, setAccount] = useState() + const authenticationLabel = t( + isConnecting + ? "settings:providers.githubCopilot.connecting" + : account + ? "settings:providers.githubCopilot.reconnect" + : "settings:providers.githubCopilot.signIn", + ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + const onMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + const expectedType = isCopilot + ? VsCodeLmModelsMessageType.githubCopilotModels + : VsCodeLmModelsMessageType.vsCodeLmModels + const isSignInResult = isCopilot && message.type === VsCodeLmModelsMessageType.githubCopilotSignInResult + if (message.type !== expectedType && !isSignInResult) return - switch (message.type) { - case VsCodeLmModelsMessageType.vsCodeLmModels: - { - const newModels = message.vsCodeLmModels ?? [] - setVsCodeLmModels(newModels) - } - break - } - }, []) + if (message.type === expectedType && (message.vsCodeLmModels !== undefined || message.error)) { + setIsRefreshing(false) + } + if (isSignInResult) setIsConnecting(false) + if (message.githubCopilotAccount !== undefined) setAccount(message.githubCopilotAccount ?? undefined) + if (isSignInResult || message.error) setError(message.error) + if (!isCopilot && !message.error && message.vsCodeLmModels) setLegacyModels(message.vsCodeLmModels) + }, + [isCopilot], + ) useEvent("message", onMessage) @@ -42,12 +76,14 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL const modelsRecord = useMemo((): Record => { return vsCodeLmModels.reduce( (acc, model) => { - const modelId = `${model.vendor}/${model.family}` + const modelId = model.id ?? `${model.vendor}/${model.family}` acc[modelId] = { maxTokens: 0, - contextWindow: 0, + contextWindow: model.maxInputTokens ?? 0, supportsPromptCache: false, + displayName: model.name, description: `${model.vendor} - ${model.family}`, + ...model.modelInfo, } return acc }, @@ -56,20 +92,118 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL }, [vsCodeLmModels]) // Transform string model ID to { vendor, family } object for storage - const valueTransform = useCallback((modelId: string) => { - const [vendor, family] = modelId.split("/") - return { vendor, family } - }, []) + const valueTransform = useCallback( + (modelId: string) => { + const model = vsCodeLmModels.find((candidate) => candidate.id === modelId) + if (model) { + return { + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + } + } + const [vendor, family] = modelId.split("/") + return { vendor, family } + }, + [vsCodeLmModels], + ) // Transform stored { vendor, family } object back to display string - const displayTransform = useCallback((value: unknown) => { - if (!value) return "" - const selector = value as { vendor?: string; family?: string } - return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" - }, []) + const displayTransform = useCallback( + (value: unknown) => { + if (!value) return "" + const selector = value as LanguageModelChatSelector + if (selector.id && vsCodeLmModels.some((model) => model.id === selector.id)) return selector.id + const model = vsCodeLmModels.find( + (candidate) => candidate.vendor === selector.vendor && candidate.family === selector.family, + ) + if (model?.id) return model.id + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + }, + [vsCodeLmModels], + ) return ( <> + {isCopilot && account && ( +
+ {t("settings:providers.githubCopilot.signedIn", { account })} +
+ )} + {isCopilot && ( +
+ + + + + + + {account && ( + + + + )} +
+ )} + {error && ( +
+ {error} +
+ )} {vsCodeLmModels.length > 0 ? (
- {t("settings:providers.vscodeLmDescription")} + {t( + isCopilot + ? "settings:providers.githubCopilot.noModels" + : "settings:providers.vscodeLmDescription", + )}
)} diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index e355baa08c..fce2ca5477 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -1,38 +1,455 @@ -import { fireEvent, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { QueryClientProvider } from "@tanstack/react-query" -import { providerIdentifiers, type OrganizationAllowList, type RouterModels } from "@roo-code/types" +import { + providerIdentifiers, + VsCodeLmModelsMessageType, + type OrganizationAllowList, + type RouterModels, +} from "@roo-code/types" import { vscode } from "@src/utils/vscode" +import { createTestQueryClient } from "@src/utils/test-utils" import { Unbound } from "../Unbound" import { VercelAiGateway } from "../VercelAiGateway" +import { VSCodeLM } from "../VSCodeLM" +import { PROVIDERS_WITH_CUSTOM_MODEL_UI } from "../../utils/providerModelConfig" -const { modelPickerMock } = vi.hoisted(() => ({ modelPickerMock: vi.fn(() => null) })) +const { modelPickerMock } = vi.hoisted(() => ({ + modelPickerMock: vi.fn((_props: React.ComponentProps) => null), +})) vi.mock("@src/i18n/TranslationContext", () => ({ - useAppTranslation: () => ({ t: (key: string) => key }), + useAppTranslation: () => ({ + t: (key: string, options?: { account?: string }) => + key === "settings:providers.githubCopilot.signedIn" ? `Signed in as ${options?.account}` : key, + }), })) vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock("@src/components/ui", () => ({ - Button: ({ children, onClick }: React.ComponentProps<"button">) => , -})) +vi.mock("@src/components/ui", async () => { + const { Button } = await import("@src/components/ui/button") + return { + Button, + StandardTooltip: ({ children, content }: { children: React.ReactNode; content: React.ReactNode }) => ( + + {children} + + ), + } +}) vi.mock("../../ModelPicker", () => ({ ModelPicker: modelPickerMock })) vi.mock("@src/components/common/VSCodeButtonLink", () => ({ VSCodeButtonLink: () => null })) +// The Copilot model list lives in the shared query cache, so every render needs a client. +const renderWithQuery = (ui: React.ReactElement) => + render({ui}) + describe("provider model routing", () => { const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } beforeEach(() => vi.clearAllMocks()) + it("uses only the custom model picker for GitHub Copilot", () => { + expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain(providerIdentifiers.githubCopilot) + }) + + it("uses compact icon-only buttons with accessible names, stable dimensions, and shared tooltips", () => { + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + const controls = [ + screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" }), + screen.getByRole("button", { name: "settings:providers.refreshModels.label" }), + screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" }), + ] + for (const control of controls) { + expect(control).toHaveClass("h-7", "w-7", "focus-visible:ring-1") + expect(control.textContent).toBe("") + expect(control).not.toHaveAttribute("title") + expect(control.querySelector("svg")).toHaveAttribute("aria-hidden", "true") + expect(control.closest('[data-testid="action-tooltip"]')).toHaveAttribute("data-tooltip") + } + expect( + screen.getByRole("group", { name: "settings:providers.githubCopilot.accountActions" }), + ).toBeInTheDocument() + }) + + it("supports keyboard tab navigation and manage-account activation", async () => { + const frame = document.createElement("iframe") + frame.hidden = true + document.body.appendChild(frame) + const nativeFocus = frame.contentDocument?.createElement("button").focus + const originalFocus = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "focus") + if (!nativeFocus || !originalFocus) throw new Error("Native DOM focus is unavailable") + Object.defineProperty(HTMLElement.prototype, "focus", { + configurable: true, + writable: true, + value: nativeFocus, + }) + try { + const user = userEvent.setup() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: "Test User", + }, + }), + ), + ) + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" })).toHaveFocus() + await user.keyboard("{Enter}") + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotManageAccount }) + } finally { + Object.defineProperty(HTMLElement.prototype, "focus", originalFocus) + frame.remove() + } + }) + + it("blocks overlapping actions during refresh and releases them after a discovery error", async () => { + const user = userEvent.setup() + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + await user.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "true", + ) + for (const button of screen.getAllByRole("button")) expect(button).toBeDisabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, error: "Model discovery failed" }, + }), + ), + ) + expect(screen.getByRole("alert")).toHaveTextContent("Model discovery failed") + for (const button of screen.getAllByRole("button")) expect(button).toBeEnabled() + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "false", + ) + }) + + it("shows completed authentication before model discovery and restores it on refresh", () => { + renderWithQuery( + , + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: "Test User", + }, + }), + ), + ) + expect(screen.getByRole("status")).toHaveTextContent("Signed in as Test User") + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })).toBeEnabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: null, + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + + it("reconnects using a fresh-session message rather than repeating passive sign-in", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotReconnect }) + }) + + it("opens VS Code's account management without signing out or changing the connected account", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" })) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotManageAccount }) + // Only the host knows whether the user signed out; the connected account stays until it says otherwise. + expect(screen.getByRole("status")).toHaveTextContent("Signed in as Test User") + }) + + it("offers account management only once an account is connected", () => { + renderWithQuery( + , + ) + expect( + screen.queryByRole("button", { name: "settings:providers.githubCopilot.manageAccount" }), + ).not.toBeInTheDocument() + }) + + it("drops the account when the host reports it is gone, for example after signing out in VS Code", () => { + renderWithQuery( + , + ) + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + expect(screen.getByRole("status")).toBeInTheDocument() + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: null, vsCodeLmModels: [] }) + + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })).toBeEnabled() + }) + + it("shows live model capabilities without writing them into saved settings", async () => { + const setField = vi.fn() + renderWithQuery( + , + ) + const modelInfo = { contextWindow: 260000, supportsImages: true, supportsPromptCache: false, maxTools: 200 } + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [ + { vendor: "copilot", id: "model", family: "dynamic-vision", version: "1", modelInfo }, + ], + }, + }), + ), + ) + + await waitFor(() => expect(modelPickerMock.mock.lastCall![0].models?.model).toMatchObject(modelInfo)) + expect(setField).not.toHaveBeenCalled() + }) + + it("keeps the last known models when a mid-sign-in update carries only the account", async () => { + renderWithQuery( + , + ) + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + send({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [{ vendor: "copilot", id: "kept", family: "kept", version: "1" }], + }) + await waitFor(() => expect(modelPickerMock.mock.lastCall![0].models).toHaveProperty("kept")) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + // The account arrives; the account-only update must not have emptied the list. + await screen.findByRole("status") + + expect(modelPickerMock.mock.lastCall![0].models).toHaveProperty("kept") + }) + + it("keeps sign-in pending during background discovery and displays cancellation", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotSignIn }) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + error: "Sign-in cancelled", + }, + }), + ), + ) + expect(screen.getByRole("alert")).toHaveTextContent("Sign-in cancelled") + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })).toBeEnabled() + }) + + it("refreshes only Copilot models and preserves distinct model variants", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + expect(postMessage).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.requestVsCodeLmModels, + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [{ id: "other-model", vendor: "other", family: "gpt-4o" }], + }, + }), + ), + ) + expect(modelPickerMock).not.toHaveBeenCalled() + + const model = { + id: "gpt-4o-version-2", + vendor: "copilot", + family: "gpt-4o", + version: "2", + maxInputTokens: 32000, + } + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [{ ...model, id: "gpt-4o-version-1", version: "1" }, model], + }, + }), + ), + ) + const props = modelPickerMock.mock.lastCall![0] + expect(Object.keys(props.models ?? {})).toEqual(["gpt-4o-version-1", "gpt-4o-version-2"]) + expect(props.valueTransform?.(model.id)).toEqual({ + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + }) + expect(props.displayTransform?.({ vendor: model.vendor, family: model.family, id: model.id })).toBe(model.id) + }) + + it("keeps the legacy VS Code LM provider free of Copilot login controls", () => { + renderWithQuery( + , + ) + expect( + screen.queryByRole("button", { name: "settings:providers.githubCopilot.signIn" }), + ).not.toBeInTheDocument() + }) + it("requests fresh Unbound models when the refresh button is clicked", () => { const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) - render( + renderWithQuery( { ) as RouterModels routerModels[providerIdentifiers.vercelAiGateway] = models - render( + renderWithQuery( { }) it("passes an empty model set when Vercel AI Gateway models are unavailable", () => { - render( + renderWithQuery( ({ id, vendor: "copilot", family: id, version: "1" }) + +const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + +const setup = (enabled = true) => { + const client = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return renderHook(({ on }) => useGitHubCopilotModels(on), { wrapper, initialProps: { on: enabled } }) +} + +describe("useGitHubCopilotModels", () => { + it("has no models until the extension reports some", () => { + const { result } = setup() + expect(result.current.data).toBeUndefined() + }) + + it("holds the list the extension pushes", async () => { + const { result } = setup() + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a"), model("b")] }) + + await waitFor(() => expect(result.current.data?.map((m) => m.id)).toEqual(["a", "b"])) + }) + + it("takes the list delivered with a completed sign-in", async () => { + const { result } = setup() + + send({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + githubCopilotAccount: "Test User", + vsCodeLmModels: [model("signed-in")], + }) + + await waitFor(() => expect(result.current.data?.map((m) => m.id)).toEqual(["signed-in"])) + }) + + it("treats an empty list as the truth, since an account may genuinely have no models", async () => { + const { result } = setup() + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a")] }) + await waitFor(() => expect(result.current.data).toHaveLength(1)) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [] }) + + await waitFor(() => expect(result.current.data).toEqual([])) + }) + + it("keeps the last list through an error, an account-only update, or an unrelated message", async () => { + const { result } = setup() + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("kept")] }) + await waitFor(() => expect(result.current.data).toHaveLength(1)) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, error: "host busy" }) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: [model("legacy")] }) + send({ type: VsCodeLmModelsMessageType.githubCopilotSignInResult, error: "cancelled" }) + + expect(result.current.data?.map((m) => m.id)).toEqual(["kept"]) + }) + + it("ignores messages while Copilot is not the active provider", async () => { + const { result } = setup(false) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a")] }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(result.current.data).toBeUndefined() + }) + + it("stops listening when disabled or unmounted", async () => { + const remove = vi.spyOn(window, "removeEventListener") + const { rerender, unmount } = setup() + + rerender({ on: false }) + expect(remove).toHaveBeenCalledWith("message", expect.any(Function)) + + remove.mockClear() + rerender({ on: true }) + unmount() + expect(remove).toHaveBeenCalledWith("message", expect.any(Function)) + remove.mockRestore() + }) +}) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 78810f0dd5..727b4674cd 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -37,6 +37,7 @@ import { deepSeekModels, openRouterDefaultModelId, vscodeLlmModels, + githubCopilotLanguageModel, vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, @@ -54,16 +55,19 @@ import { useRouterModels } from "../useRouterModels" import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders" import { useLmStudioModels } from "../useLmStudioModels" import { useOllamaModels } from "../useOllamaModels" +import { useGitHubCopilotModels } from "../useGitHubCopilotModels" vi.mock("../useRouterModels") vi.mock("../useOpenRouterModelProviders") vi.mock("../useLmStudioModels") vi.mock("../useOllamaModels") +vi.mock("../useGitHubCopilotModels") const mockUseRouterModels = useRouterModels as Mock const mockUseOpenRouterModelProviders = useOpenRouterModelProviders as Mock const mockUseLmStudioModels = useLmStudioModels as Mock const mockUseOllamaModels = useOllamaModels as Mock +const mockUseGitHubCopilotModels = useGitHubCopilotModels as Mock type TestRouterModels = Partial> type QueryResultOptions = { isLoading?: boolean; isError?: boolean } @@ -109,6 +113,7 @@ describe("useSelectedModel", () => { mockUseOllamaModels.mockClear() mockUseLmStudioModels.mockReturnValue(createLocalModelsResult({})) mockUseOllamaModels.mockReturnValue(createLocalModelsResult({})) + mockUseGitHubCopilotModels.mockReturnValue({ data: undefined } as ReturnType) }) const dynamicProviderCases = [ @@ -1395,6 +1400,96 @@ describe("useSelectedModel", () => { }) describe("vscode-lm provider", () => { + const reportedModels = (...models: Array<{ id: string; family: string; modelInfo?: ModelInfo }>) => + mockUseGitHubCopilotModels.mockReturnValue({ + data: models.map((model) => ({ vendor: githubCopilotLanguageModel.vendor, version: "1", ...model })), + } as unknown as ReturnType) + + const selectCopilot = (selector: NonNullable) => + renderHook(() => + useSelectedModel({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector: selector }), + ).result.current + + it("only subscribes to the Copilot list while Copilot is the active provider", () => { + renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.vscodeLm })) + expect(mockUseGitHubCopilotModels).toHaveBeenLastCalledWith(false) + + selectCopilot({ vendor: githubCopilotLanguageModel.vendor, id: "any" }) + expect(mockUseGitHubCopilotModels).toHaveBeenLastCalledWith(true) + }) + + it("describes a model from the curated catalog until the host has reported it", () => { + const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) ?? [] + expect(curatedFamily).toBeDefined() + + expect( + selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family: curatedFamily }).info + ?.supportsImages, + ).toBe(true) + }) + + it("keeps missing Copilot image capability unknown instead of setting it to false", () => { + expect( + selectCopilot({ vendor: "copilot", id: "unknown-model", family: "unknown-model" }).info?.supportsImages, + ).toBeUndefined() + }) + + it("uses what the host reports for the selected model: context, vision, and tool limit", () => { + reportedModels({ + id: "dynamic-vision", + family: "dynamic-vision", + modelInfo: { contextWindow: 260000, supportsImages: true, supportsPromptCache: false, maxTools: 200 }, + }) + + const selected = selectCopilot({ vendor: "copilot", id: "dynamic-vision", family: "dynamic-vision" }) + + expect(selected.id).toBe("dynamic-vision") + expect(selected.info).toMatchObject({ + contextWindow: 260000, + supportsImages: true, + supportsPromptCache: false, + maxTools: 200, + }) + }) + + it("lets a host report of no vision override the curated catalog", () => { + const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) ?? [] + reportedModels({ + id: "text-only", + family: curatedFamily!, + modelInfo: { contextWindow: 1000, supportsImages: false, supportsPromptCache: false }, + }) + + expect( + selectCopilot({ vendor: "copilot", id: "text-only", family: curatedFamily }).info?.supportsImages, + ).toBe(false) + }) + + it("picks the reported model matching the saved id, not merely the first one", () => { + reportedModels( + { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, + { id: "wanted", family: "wanted", modelInfo: { contextWindow: 777, supportsPromptCache: false } }, + ) + + expect(selectCopilot({ vendor: "copilot", id: "wanted" }).info?.contextWindow).toBe(777) + }) + + it("follows a refreshed report for the same selection", () => { + reportedModels({ id: "m", family: "m", modelInfo: { contextWindow: 100, supportsPromptCache: false } }) + const { result, rerender } = renderHook(() => + useSelectedModel({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "m" }, + }), + ) + expect(result.current.info?.contextWindow).toBe(100) + + reportedModels({ id: "m", family: "m", modelInfo: { contextWindow: 200, supportsPromptCache: false } }) + rerender() + + expect(result.current.info?.contextWindow).toBe(200) + }) + beforeEach(() => { mockUseRouterModels.mockReturnValue(createRouterModelsResult({ openrouter: {}, requesty: {}, litellm: {} })) diff --git a/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts b/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts new file mode 100644 index 0000000000..c8afc0a3fb --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react" +import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query" + +import { type ExtensionMessage, VsCodeLmModelsMessageType } from "@roo-code/types" + +export type GitHubCopilotModels = NonNullable + +const GITHUB_COPILOT_MODELS_QUERY_KEY = ["githubCopilotModels"] as const + +/** + * The Copilot models VS Code currently offers, with the capabilities VS Code reports for each. + * + * The extension is the source of truth and pushes this list: when the view opens, after sign-in, + * on a manual refresh, and whenever the host registers or removes models. The hook only holds the + * latest push in the shared query cache, so the model picker and model selection read one list, and + * nothing about a model is ever copied into saved settings where it could go stale. + * + * A message that carries an error, or no list at all (the account-only update sent mid sign-in), + * leaves the last known list in place rather than reading as "no models". + */ +export const useGitHubCopilotModels = (enabled: boolean) => { + const queryClient = useQueryClient() + + useEffect(() => { + if (!enabled) return + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + const carriesModels = + message.type === VsCodeLmModelsMessageType.githubCopilotModels || + message.type === VsCodeLmModelsMessageType.githubCopilotSignInResult + if (carriesModels && !message.error && message.vsCodeLmModels) { + queryClient.setQueryData(GITHUB_COPILOT_MODELS_QUERY_KEY, message.vsCodeLmModels) + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [enabled, queryClient]) + + // `skipToken`: this query never fetches; its data only ever arrives from the extension. + return useQuery({ queryKey: GITHUB_COPILOT_MODELS_QUERY_KEY, queryFn: skipToken }) +} diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 37593a9538..3d8aec1ca8 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -41,12 +41,15 @@ import { isRetiredProvider, getProviderDefaultModelId, providerIdentifiers, + githubCopilotLanguageModel, + vscodeLlmBaselineModelInfo, } from "@roo-code/types" import { useRouterModels } from "./useRouterModels" import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders" import { useLmStudioModels } from "./useLmStudioModels" import { useOllamaModels } from "./useOllamaModels" +import { useGitHubCopilotModels, type GitHubCopilotModels } from "./useGitHubCopilotModels" /** * Helper to get a validated model ID for dynamic providers. @@ -112,6 +115,8 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId) const lmStudioModels = useLmStudioModels(lmStudioModelId) const ollamaModels = useOllamaModels(ollamaModelId) + // Not part of readiness: until the host reports, the curated catalog describes the model. + const copilotModels = useGitHubCopilotModels(activeProvider === providerIdentifiers.githubCopilot) // Compute readiness only for the data actually needed for the selected provider const needRouterModels = shouldFetchRouterModels @@ -149,6 +154,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { openRouterModelProviders: (openRouterModelProviders.data || {}) as Record, lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined, ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined, + copilotModels: copilotModels.data, }) : activeProvider === providerIdentifiers.kimiCode && apiConfiguration ? { @@ -181,6 +187,7 @@ function getSelectedModel({ openRouterModelProviders, lmStudioModels, ollamaModels, + copilotModels, }: { provider: ProviderName apiConfiguration: ProviderSettings @@ -188,6 +195,7 @@ function getSelectedModel({ openRouterModelProviders: Record lmStudioModels: ModelRecord | undefined ollamaModels: ModelRecord | undefined + copilotModels?: GitHubCopilotModels }): { id: string; info: ModelInfo | undefined } { // the `undefined` case are used to show the invalid selection to prevent // users from seeing the default model if their selection is invalid @@ -428,6 +436,31 @@ function getSelectedModel({ info: modelInfo ? { ...lMStudioDefaultModelInfo, ...modelInfo } : undefined, } } + case providerIdentifiers.githubCopilot: { + const selector = apiConfiguration.vsCodeLmModelSelector + // What the host reports now wins; the curated catalog only describes a model it has not reported. + const reported = selector?.id + ? copilotModels?.find((model) => model.id === selector.id) + : selector?.family + ? copilotModels?.find((model) => model.family === selector.family) + : undefined + const knownModel = selector?.family + ? vscodeLlmModels[selector.family as keyof typeof vscodeLlmModels] + : undefined + return { + id: + selector?.id ?? + (selector?.family + ? `${githubCopilotLanguageModel.vendor}/${selector.family}` + : vscodeLlmDefaultModelId), + info: { + ...vscodeLlmBaselineModelInfo, + ...knownModel, + ...reported?.modelInfo, + supportsPromptCache: false, + }, + } + } case providerIdentifiers.vscodeLm: { const id = apiConfiguration?.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` diff --git a/webview-ui/src/hooks/useTooManyTools.ts b/webview-ui/src/hooks/useTooManyTools.ts index ba18b10a70..2284c3a7d7 100644 --- a/webview-ui/src/hooks/useTooManyTools.ts +++ b/webview-ui/src/hooks/useTooManyTools.ts @@ -1,7 +1,8 @@ import { useMemo } from "react" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools } from "@roo-code/types" +import { countEnabledMcpTools, getMcpToolThreshold } from "@roo-code/types" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" export interface TooManyToolsInfo { /** Number of enabled and connected MCP servers */ @@ -32,25 +33,27 @@ export interface TooManyToolsInfo { */ export function useTooManyTools(): TooManyToolsInfo { const { t } = useAppTranslation() - const { mcpServers } = useExtensionState() + const { mcpServers, apiConfiguration } = useExtensionState() + const { info } = useSelectedModel(apiConfiguration) const { enabledServerCount, enabledToolCount } = useMemo(() => countEnabledMcpTools(mcpServers), [mcpServers]) - const isOverThreshold = enabledToolCount > MAX_MCP_TOOLS_THRESHOLD + const threshold = getMcpToolThreshold(info) + const isOverThreshold = enabledToolCount > threshold const toolsPart = t("chat:tooManyTools.toolsPart", { count: enabledToolCount }) const serversPart = t("chat:tooManyTools.serversPart", { count: enabledServerCount }) const message = t("chat:tooManyTools.messageTemplate", { tools: toolsPart, servers: serversPart, - threshold: MAX_MCP_TOOLS_THRESHOLD, + threshold, }) return { enabledServerCount, enabledToolCount, isOverThreshold, - threshold: MAX_MCP_TOOLS_THRESHOLD, + threshold, title: t("chat:tooManyTools.title"), message, } diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f599e40db9..3c762d5aa2 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Habilitar emmagatzematge en caché de prompts", "enablePromptCachingTitle": "Habilitar l'emmagatzematge en caché de prompts per millorar el rendiment i reduir els costos per als models compatibles.", "cacheUsageNote": "Nota: Si no veieu l'ús de la caché, proveu de seleccionar un model diferent i després tornar a seleccionar el model desitjat.", + "githubCopilot": { + "accountActions": "Accions del compte de GitHub Copilot", + "signIn": "Inicia la sessió a GitHub", + "reconnect": "Torna a connectar GitHub", + "signedIn": "Sessió iniciada com a {{account}}", + "connecting": "Connectant...", + "noModels": "No hi ha models de Copilot disponibles. Instal·la o activa GitHub Copilot Chat, inicia la sessió amb un compte de GitHub elegible i actualitza. Iniciar la sessió no confirma una subscripció a Copilot. S'apliquen els límits del teu pla de GitHub, de sol·licituds premium i de taxa.", + "manageAccount": "Gestiona el compte de GitHub", + "manageAccountDescription": "Obre el menú de comptes de VS Code, on pots tancar la sessió de GitHub. Tancar la sessió afecta Copilot i altres extensions que utilitzen aquest compte." + }, "vscodeLmModel": "Model de llenguatge", "vscodeLmWarning": "Nota: Els models accessibles a través de l’API VS Code Language Model poden estar encapsulats o ajustats pel proveïdor; per tant, el comportament pot diferir de l’ús directe del mateix model des d’un proveïdor o enrutador típic. Per utilitzar un model del desplegable «Language Model», primer canvia a aquest model i després fes clic a «Acceptar» a l’avís de Copilot Chat; en cas contrari pots veure un error com 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Valors més alts fan que la sortida sigui més aleatòria, valors més baixos la fan més determinista." }, "modelInfo": { + "imageSupportUnknown": "Compatibilitat amb imatges no informada", + "promptCacheManagedByCopilot": "La memòria cau de prompts la gestiona GitHub Copilot", + "promptCacheCopilotDetails": "La memòria cau depèn del model i del backend de GitHub Copilot. Zoo Code no pot configurar la retenció de la memòria cau ni observar-ne els encerts a través de l'API de VS Code.", "supportsImages": "Suporta imatges", "noImages": "No suporta imatges", "supportsPromptCache": "Suporta emmagatzematge en caché de prompts", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 845c9e575f..6ebeedc3e2 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Prompt-Caching aktivieren", "enablePromptCachingTitle": "Prompt-Caching aktivieren, um die Leistung zu verbessern und Kosten für unterstützte Modelle zu reduzieren.", "cacheUsageNote": "Hinweis: Wenn Sie keine Cache-Nutzung sehen, versuchen Sie ein anderes Modell auszuwählen und dann Ihr gewünschtes Modell erneut auszuwählen.", + "githubCopilot": { + "accountActions": "Aktionen für das GitHub-Copilot-Konto", + "signIn": "Bei GitHub anmelden", + "reconnect": "GitHub erneut verbinden", + "signedIn": "Angemeldet als {{account}}", + "connecting": "Verbindung wird hergestellt...", + "noModels": "Keine Copilot-Modelle verfügbar. Installiere oder aktiviere GitHub Copilot Chat, melde dich mit einem berechtigten GitHub-Konto an und aktualisiere dann. Die Anmeldung bestätigt kein Copilot-Abonnement. Es gelten die Grenzen deines GitHub-Tarifs sowie für Premium-Anfragen und Anfragehäufigkeit.", + "manageAccount": "GitHub-Konto verwalten", + "manageAccountDescription": "Öffnet das Kontomenü von VS Code, in dem du dich von GitHub abmelden kannst. Das Abmelden betrifft Copilot und andere Erweiterungen, die dieses Konto verwenden." + }, "vscodeLmModel": "Sprachmodell", "vscodeLmWarning": "Hinweis: Über die VS Code Language Model API abgerufene Modelle können vom Anbieter ummantelt oder feinabgestimmt sein. Daher kann sich ihr Verhalten von der direkten Nutzung desselben Modells bei einem typischen Anbieter oder Router unterscheiden. Um ein Modell aus der Auswahlliste „Language Model“ zu verwenden, wechsle zunächst zu diesem Modell und klicke dann im Copilot‑Chat auf „Akzeptieren“; andernfalls kann ein Fehler wie 400 „The requested model is not supported“ auftreten.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Höhere Werte machen die Ausgabe zufälliger, niedrigere Werte machen sie deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Bildunterstützung nicht gemeldet", + "promptCacheManagedByCopilot": "Prompt-Caching wird von GitHub Copilot verwaltet", + "promptCacheCopilotDetails": "Das Caching hängt vom Modell und vom GitHub-Copilot-Backend ab. Zoo Code kann die Cache-Aufbewahrung nicht konfigurieren und Cache-Treffer über die VS-Code-API nicht beobachten.", "supportsImages": "Unterstützt Bilder", "noImages": "Unterstützt keine Bilder", "supportsPromptCache": "Unterstützt Prompt-Cache", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index de36f7ac3a..e9fc98d4d3 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -659,6 +659,16 @@ "enablePromptCaching": "Enable prompt caching", "enablePromptCachingTitle": "Enable prompt caching to improve performance and reduce costs for supported models.", "cacheUsageNote": "Note: If you don't see cache usage, try selecting a different model and then selecting your desired model again.", + "githubCopilot": { + "accountActions": "GitHub Copilot account actions", + "signIn": "Sign in to GitHub", + "reconnect": "Reconnect GitHub", + "signedIn": "Signed in as {{account}}", + "connecting": "Connecting...", + "noModels": "No Copilot models available. Install or enable GitHub Copilot Chat, sign in with an eligible GitHub account, then refresh. Signing in does not confirm a Copilot subscription. GitHub plan, premium-request, and rate limits apply.", + "manageAccount": "Manage GitHub account", + "manageAccountDescription": "Opens VS Code's account menu, where you can sign out of GitHub. Signing out affects Copilot and other extensions using this account." + }, "vscodeLmModel": "Language Model", "vscodeLmWarning": "Note: Models accessed via the VS Code Language Model API may be wrapped or fine-tuned by the provider, so behavior can differ from using the same model directly from a typical provider or router. To use a model from the Language Model dropdown, first switch to that model and then click Accept in the Copilot Chat prompt; otherwise you may see an error such as 400 \"The requested model is not supported\".", "googleCloudSetup": { @@ -1082,6 +1092,9 @@ "rangeDescription": "Higher values make output more random, lower values make it more deterministic." }, "modelInfo": { + "imageSupportUnknown": "Image support not reported", + "promptCacheManagedByCopilot": "Prompt caching managed by GitHub Copilot", + "promptCacheCopilotDetails": "Caching depends on the model and GitHub Copilot backend. Zoo Code cannot configure cache retention or observe cache hits through the VS Code API.", "supportsImages": "Supports images", "noImages": "Does not support images", "supportsPromptCache": "Supports prompt caching", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 1e6f87d553..b1751edb7f 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Habilitar caché de prompts", "enablePromptCachingTitle": "Habilitar el caché de prompts para mejorar el rendimiento y reducir costos para modelos compatibles.", "cacheUsageNote": "Nota: Si no ve el uso del caché, intente seleccionar un modelo diferente y luego seleccionar nuevamente su modelo deseado.", + "githubCopilot": { + "accountActions": "Acciones de la cuenta de GitHub Copilot", + "signIn": "Iniciar sesión en GitHub", + "reconnect": "Volver a conectar GitHub", + "signedIn": "Sesión iniciada como {{account}}", + "connecting": "Conectando...", + "noModels": "No hay modelos de Copilot disponibles. Instala o activa GitHub Copilot Chat, inicia sesión con una cuenta de GitHub elegible y actualiza. Iniciar sesión no confirma una suscripción a Copilot. Se aplican los límites de tu plan de GitHub, de solicitudes premium y de frecuencia.", + "manageAccount": "Administrar cuenta de GitHub", + "manageAccountDescription": "Abre el menú de cuentas de VS Code, donde puedes cerrar la sesión de GitHub. Cerrar sesión afecta a Copilot y a otras extensiones que usan esta cuenta." + }, "vscodeLmModel": "Modelo de lenguaje", "vscodeLmWarning": "Nota: Los modelos a los que se accede a través de la API de modelos de lenguaje de VS Code pueden estar envueltos o ajustados por el proveedor, por lo que su comportamiento puede diferir del uso directo del mismo modelo desde un proveedor o enrutador típico. Para usar un modelo del menú desplegable «Language Model», primero cambia a ese modelo y luego haz clic en «Aceptar» en el aviso de Copilot Chat; de lo contrario, puedes ver un error como 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Valores más altos hacen que la salida sea más aleatoria, valores más bajos la hacen más determinista." }, "modelInfo": { + "imageSupportUnknown": "Compatibilidad con imágenes no informada", + "promptCacheManagedByCopilot": "El almacenamiento en caché de prompts lo gestiona GitHub Copilot", + "promptCacheCopilotDetails": "El almacenamiento en caché depende del modelo y del backend de GitHub Copilot. Zoo Code no puede configurar la retención de la caché ni observar sus aciertos a través de la API de VS Code.", "supportsImages": "Soporta imágenes", "noImages": "No soporta imágenes", "supportsPromptCache": "Soporta caché de prompts", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ec6ee0f692..bd5fb18135 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Activer la mise en cache des prompts", "enablePromptCachingTitle": "Activer la mise en cache des prompts pour améliorer les performances et réduire les coûts pour les modèles pris en charge.", "cacheUsageNote": "Remarque : Si vous ne voyez pas l'utilisation du cache, essayez de sélectionner un modèle différent puis de sélectionner à nouveau votre modèle souhaité.", + "githubCopilot": { + "accountActions": "Actions du compte GitHub Copilot", + "signIn": "Se connecter à GitHub", + "reconnect": "Reconnecter GitHub", + "signedIn": "Connecté en tant que {{account}}", + "connecting": "Connexion...", + "noModels": "Aucun modèle Copilot disponible. Installez ou activez GitHub Copilot Chat, connectez-vous avec un compte GitHub éligible, puis actualisez. La connexion ne confirme pas un abonnement Copilot. Les limites de votre forfait GitHub, des requêtes premium et de débit s'appliquent.", + "manageAccount": "Gérer le compte GitHub", + "manageAccountDescription": "Ouvre le menu des comptes de VS Code, où vous pouvez vous déconnecter de GitHub. La déconnexion affecte Copilot et les autres extensions qui utilisent ce compte." + }, "vscodeLmModel": "Modèle de langage", "vscodeLmWarning": "Remarque : Les modèles accessibles via l’API VS Code Language Model peuvent être encapsulés ou ajustés par le fournisseur ; leur comportement peut donc différer de l’utilisation directe du même modèle auprès d’un fournisseur ou routeur classique. Pour utiliser un modèle depuis la liste « Language Model », bascule d’abord sur ce modèle puis clique sur « Accepter » dans l’invite de Copilot Chat ; sinon, une erreur telle que 400 « The requested model is not supported » peut apparaître.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Des valeurs plus élevées rendent la sortie plus aléatoire, des valeurs plus basses la rendent plus déterministe." }, "modelInfo": { + "imageSupportUnknown": "Prise en charge des images non signalée", + "promptCacheManagedByCopilot": "Le cache des prompts est géré par GitHub Copilot", + "promptCacheCopilotDetails": "La mise en cache dépend du modèle et du backend de GitHub Copilot. Zoo Code ne peut ni configurer la rétention du cache ni observer les succès de cache via l'API de VS Code.", "supportsImages": "Prend en charge les images", "noImages": "Ne prend pas en charge les images", "supportsPromptCache": "Prend en charge la mise en cache des prompts", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index fa725cd2c5..83dafa70d1 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "प्रॉम्प्ट कैशिंग सक्षम करें", "enablePromptCachingTitle": "समर्थित मॉडल के लिए प्रदर्शन में सुधार और लागत को कम करने के लिए प्रॉम्प्ट कैशिंग सक्षम करें।", "cacheUsageNote": "नोट: यदि आप कैश उपयोग नहीं देखते हैं, तो एक अलग मॉडल चुनने का प्रयास करें और फिर अपने वांछित मॉडल को पुनः चुनें।", + "githubCopilot": { + "accountActions": "GitHub Copilot खाते की क्रियाएँ", + "signIn": "GitHub में साइन इन करें", + "reconnect": "GitHub फिर से कनेक्ट करें", + "signedIn": "{{account}} के रूप में साइन इन किया गया", + "connecting": "कनेक्ट हो रहा है...", + "noModels": "कोई Copilot मॉडल उपलब्ध नहीं है। GitHub Copilot Chat इंस्टॉल या सक्षम करें, किसी पात्र GitHub खाते से साइन इन करें, फिर रिफ्रेश करें। साइन इन करने से Copilot सदस्यता की पुष्टि नहीं होती। आपके GitHub प्लान, प्रीमियम-अनुरोध और दर सीमाएँ लागू होती हैं।", + "manageAccount": "GitHub खाता प्रबंधित करें", + "manageAccountDescription": "VS Code का खाता मेनू खोलता है, जहाँ आप GitHub से साइन आउट कर सकते हैं। साइन आउट करने से Copilot और इस खाते का उपयोग करने वाले अन्य एक्सटेंशन प्रभावित होते हैं।" + }, "vscodeLmModel": "भाषा मॉडल", "vscodeLmWarning": "नोट: VS Code Language Model API के माध्यम से उपलब्ध मॉडल प्रदाता द्वारा रैप या फाइन‑ट्यून किए जा सकते हैं, इसलिए इनका व्यवहार किसी सामान्य प्रदाता या राउटर से सीधे उसी मॉडल का उपयोग करने की तुलना में अलग हो सकता है। «Language Model» ड्रॉपडाउन से मॉडल उपयोग करने के लिए पहले उसी मॉडल पर स्विच करें और फिर Copilot Chat प्रॉम्प्ट में «Accept» पर क्लिक करें; अन्यथा 400 \"The requested model is not supported\" जैसी त्रुटि दिखाई दे सकती है।", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "उच्च मान आउटपुट को अधिक यादृच्छिक बनाते हैं, निम्न मान इसे अधिक निर्धारित बनाते हैं।" }, "modelInfo": { + "imageSupportUnknown": "छवि समर्थन की जानकारी नहीं दी गई", + "promptCacheManagedByCopilot": "प्रॉम्प्ट कैशिंग GitHub Copilot द्वारा प्रबंधित है", + "promptCacheCopilotDetails": "कैशिंग मॉडल और GitHub Copilot बैकएंड पर निर्भर करती है। Zoo Code VS Code API के माध्यम से कैश प्रतिधारण को कॉन्फ़िगर नहीं कर सकता और न ही कैश हिट देख सकता है।", "supportsImages": "छवियों का समर्थन करता है", "noImages": "छवियों का समर्थन नहीं करता है", "supportsPromptCache": "प्रॉम्प्ट कैशिंग का समर्थन करता है", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bd3e0fcc96..d7f9f1dd21 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Aktifkan prompt caching", "enablePromptCachingTitle": "Aktifkan prompt caching untuk meningkatkan performa dan mengurangi biaya untuk model yang didukung.", "cacheUsageNote": "Catatan: Jika kamu tidak melihat penggunaan cache, coba pilih model yang berbeda lalu pilih model yang kamu inginkan lagi.", + "githubCopilot": { + "accountActions": "Tindakan akun GitHub Copilot", + "signIn": "Masuk ke GitHub", + "reconnect": "Hubungkan ulang GitHub", + "signedIn": "Masuk sebagai {{account}}", + "connecting": "Menghubungkan...", + "noModels": "Tidak ada model Copilot yang tersedia. Instal atau aktifkan GitHub Copilot Chat, masuk dengan akun GitHub yang memenuhi syarat, lalu segarkan. Masuk tidak mengonfirmasi langganan Copilot. Batas paket GitHub, permintaan premium, dan laju permintaan berlaku.", + "manageAccount": "Kelola akun GitHub", + "manageAccountDescription": "Membuka menu akun VS Code, tempat Anda dapat keluar dari GitHub. Keluar memengaruhi Copilot dan ekstensi lain yang menggunakan akun ini." + }, "vscodeLmModel": "Model Bahasa", "vscodeLmWarning": "Catatan: Model yang diakses melalui VS Code Language Model API dapat dibungkus atau disetel‑halus oleh penyedia, sehingga perilakunya dapat berbeda dibandingkan menggunakan model yang sama secara langsung dari penyedia atau router tipikal. Untuk menggunakan model dari menu tarik‑turun «Language Model», pertama beralihlah ke model tersebut lalu klik «Terima» pada prompt Copilot Chat; jika tidak, Anda mungkin melihat kesalahan seperti 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Nilai yang lebih tinggi membuat output lebih acak, nilai yang lebih rendah membuatnya lebih deterministik." }, "modelInfo": { + "imageSupportUnknown": "Dukungan gambar tidak dilaporkan", + "promptCacheManagedByCopilot": "Prompt caching dikelola oleh GitHub Copilot", + "promptCacheCopilotDetails": "Caching bergantung pada model dan backend GitHub Copilot. Zoo Code tidak dapat mengonfigurasi retensi cache atau mengamati cache hit melalui API VS Code.", "supportsImages": "Mendukung gambar", "noImages": "Tidak mendukung gambar", "supportsPromptCache": "Mendukung prompt caching", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 2b604e720f..ae83fd381a 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Abilita cache dei prompt", "enablePromptCachingTitle": "Abilita la cache dei prompt per migliorare le prestazioni e ridurre i costi per i modelli supportati.", "cacheUsageNote": "Nota: Se non vedi l'utilizzo della cache, prova a selezionare un modello diverso e poi seleziona nuovamente il modello desiderato.", + "githubCopilot": { + "accountActions": "Azioni dell'account GitHub Copilot", + "signIn": "Accedi a GitHub", + "reconnect": "Riconnetti GitHub", + "signedIn": "Accesso eseguito come {{account}}", + "connecting": "Connessione in corso...", + "noModels": "Nessun modello Copilot disponibile. Installa o abilita GitHub Copilot Chat, accedi con un account GitHub idoneo e poi aggiorna. L'accesso non conferma un abbonamento a Copilot. Si applicano i limiti del tuo piano GitHub, delle richieste premium e della frequenza.", + "manageAccount": "Gestisci account GitHub", + "manageAccountDescription": "Apre il menu degli account di VS Code, dove puoi disconnetterti da GitHub. La disconnessione riguarda Copilot e le altre estensioni che usano questo account." + }, "vscodeLmModel": "Modello linguistico", "vscodeLmWarning": "Nota: I modelli accessibili tramite la VS Code Language Model API possono essere incapsulati o perfezionati dal provider, quindi il comportamento può differire dall’uso diretto dello stesso modello presso un provider o router tipico. Per usare un modello dal menu a discesa «Language Model», passa prima a quel modello e poi fai clic su «Accetta» nell’avviso di Copilot Chat; in caso contrario potresti visualizzare un errore come 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Valori più alti rendono l'output più casuale, valori più bassi lo rendono più deterministico." }, "modelInfo": { + "imageSupportUnknown": "Supporto per le immagini non segnalato", + "promptCacheManagedByCopilot": "La cache dei prompt è gestita da GitHub Copilot", + "promptCacheCopilotDetails": "La memorizzazione nella cache dipende dal modello e dal backend di GitHub Copilot. Zoo Code non può configurare la conservazione della cache né osservarne gli hit tramite l'API di VS Code.", "supportsImages": "Supporta immagini", "noImages": "Non supporta immagini", "supportsPromptCache": "Supporta cache dei prompt", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 2ca3ef0396..cac7163425 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "プロンプトキャッシュを有効化", "enablePromptCachingTitle": "サポートされているモデルのパフォーマンスを向上させ、コストを削減するためにプロンプトキャッシュを有効化します。", "cacheUsageNote": "注意:キャッシュの使用が表示されない場合は、別のモデルを選択してから希望のモデルを再度選択してみてください。", + "githubCopilot": { + "accountActions": "GitHub Copilot アカウントの操作", + "signIn": "GitHub にサインイン", + "reconnect": "GitHub に再接続", + "signedIn": "{{account}} としてサインイン中", + "connecting": "接続中...", + "noModels": "利用可能な Copilot モデルがありません。GitHub Copilot Chat をインストールまたは有効化し、対象の GitHub アカウントでサインインしてから更新してください。サインインしても Copilot のサブスクリプションが確認されるわけではありません。GitHub のプラン、プレミアムリクエスト、レート制限が適用されます。", + "manageAccount": "GitHub アカウントを管理", + "manageAccountDescription": "VS Code のアカウントメニューを開きます。ここから GitHub からサインアウトできます。サインアウトすると、Copilot とこのアカウントを使用する他の拡張機能に影響します。" + }, "vscodeLmModel": "言語モデル", "vscodeLmWarning": "注意: VS Code Language Model API を通じて利用されるモデルは、プロバイダーによってラップまたは微調整されている場合があります。したがって、一般的なプロバイダーやルーターから同じモデルを直接使用する場合と挙動が異なることがあります。『Language Model』ドロップダウンのモデルを使用するには、まずそのモデルに切り替え、Copilot Chat のプロンプトで『承認』をクリックしてください。そうしないと、400『The requested model is not supported』などのエラーが表示されることがあります。", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "高い値は出力をよりランダムに、低い値はより決定論的にします。" }, "modelInfo": { + "imageSupportUnknown": "画像サポートが報告されていません", + "promptCacheManagedByCopilot": "プロンプトキャッシュは GitHub Copilot が管理します", + "promptCacheCopilotDetails": "キャッシュの動作はモデルと GitHub Copilot のバックエンドに依存します。Zoo Code は VS Code API を通じて、キャッシュの保持期間を設定したりキャッシュヒットを確認したりすることはできません。", "supportsImages": "画像をサポート", "noImages": "画像をサポートしていません", "supportsPromptCache": "プロンプトキャッシュをサポート", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 89dcd74c40..900cb244b1 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "프롬프트 캐시 활성화", "enablePromptCachingTitle": "지원되는 모델의 성능을 향상시키고 비용을 절감하기 위해 프롬프트 캐시를 활성화합니다.", "cacheUsageNote": "참고: 캐시 사용이 표시되지 않는 경우, 다른 모델을 선택한 다음 원하는 모델을 다시 선택해 보세요.", + "githubCopilot": { + "accountActions": "GitHub Copilot 계정 작업", + "signIn": "GitHub에 로그인", + "reconnect": "GitHub 다시 연결", + "signedIn": "{{account}}(으)로 로그인됨", + "connecting": "연결 중...", + "noModels": "사용 가능한 Copilot 모델이 없습니다. GitHub Copilot Chat을 설치하거나 활성화하고, 자격이 있는 GitHub 계정으로 로그인한 후 새로고침하세요. 로그인만으로는 Copilot 구독이 확인되지 않습니다. GitHub 요금제, 프리미엄 요청 및 속도 제한이 적용됩니다.", + "manageAccount": "GitHub 계정 관리", + "manageAccountDescription": "VS Code의 계정 메뉴를 엽니다. 여기에서 GitHub에서 로그아웃할 수 있습니다. 로그아웃하면 Copilot과 이 계정을 사용하는 다른 확장 프로그램에 영향을 줍니다." + }, "vscodeLmModel": "언어 모델", "vscodeLmWarning": "참고: VS Code Language Model API를 통해 액세스되는 모델은 공급자가 래핑하거나 미세 조정했을 수 있어, 일반적인 공급자나 라우터에서 동일한 모델을 직접 사용할 때와 동작이 다를 수 있습니다. ‘Language Model’ 드롭다운의 모델을 사용하려면 먼저 해당 모델로 전환한 다음 Copilot Chat 프롬프트에서 ‘허용(수락)’을 클릭하세요. 그렇지 않으면 400 ‘The requested model is not supported’와 같은 오류가 발생할 수 있습니다.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "높은 값은 출력을 더 무작위하게, 낮은 값은 더 결정적으로 만듭니다." }, "modelInfo": { + "imageSupportUnknown": "이미지 지원 여부가 보고되지 않음", + "promptCacheManagedByCopilot": "프롬프트 캐싱은 GitHub Copilot이 관리합니다", + "promptCacheCopilotDetails": "캐싱은 모델과 GitHub Copilot 백엔드에 따라 달라집니다. Zoo Code는 VS Code API를 통해 캐시 보존 기간을 설정하거나 캐시 적중 여부를 확인할 수 없습니다.", "supportsImages": "이미지 지원", "noImages": "이미지 지원 안 함", "supportsPromptCache": "프롬프트 캐시 지원", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2eeea89e92..564d637e0d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Prompt caching inschakelen", "enablePromptCachingTitle": "Schakel prompt caching in om de prestaties te verbeteren en de kosten te verlagen voor ondersteunde modellen.", "cacheUsageNote": "Let op: als je geen cachegebruik ziet, probeer dan een ander model te selecteren en vervolgens weer je gewenste model.", + "githubCopilot": { + "accountActions": "Acties voor het GitHub Copilot-account", + "signIn": "Aanmelden bij GitHub", + "reconnect": "GitHub opnieuw verbinden", + "signedIn": "Aangemeld als {{account}}", + "connecting": "Verbinden...", + "noModels": "Geen Copilot-modellen beschikbaar. Installeer of activeer GitHub Copilot Chat, meld je aan met een geschikt GitHub-account en ververs daarna. Aanmelden bevestigt geen Copilot-abonnement. De limieten van je GitHub-abonnement, premiumverzoeken en aanvraagfrequentie zijn van toepassing.", + "manageAccount": "GitHub-account beheren", + "manageAccountDescription": "Opent het accountmenu van VS Code, waar je je kunt afmelden bij GitHub. Afmelden heeft gevolgen voor Copilot en andere extensies die dit account gebruiken." + }, "vscodeLmModel": "Taalmodel", "vscodeLmWarning": "Let op: Modellen die via de VS Code Language Model API worden benaderd kunnen door de provider worden verpakt of fijn‑afgesteld, waardoor het gedrag kan afwijken van het rechtstreeks gebruiken van hetzelfde model bij een typische provider of router. Om een model uit de keuzelijst ‘Language Model’ te gebruiken, schakel eerst naar dat model en klik vervolgens op ‘Accepteren’ in de Copilot Chat‑prompt; anders kun je een fout zien zoals 400 ‘The requested model is not supported’.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Hogere waarden maken de output willekeuriger, lagere waarden maken deze deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Ondersteuning voor afbeeldingen niet gemeld", + "promptCacheManagedByCopilot": "Prompt-caching wordt beheerd door GitHub Copilot", + "promptCacheCopilotDetails": "Caching hangt af van het model en de GitHub Copilot-backend. Zoo Code kan de cachebewaartermijn niet instellen en cachetreffers niet waarnemen via de VS Code API.", "supportsImages": "Ondersteunt afbeeldingen", "noImages": "Ondersteunt geen afbeeldingen", "supportsPromptCache": "Ondersteunt prompt caching", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 0de3a36c4e..218db7346d 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Włącz buforowanie podpowiedzi", "enablePromptCachingTitle": "Włącz buforowanie podpowiedzi, aby poprawić wydajność i zmniejszyć koszty dla obsługiwanych modeli.", "cacheUsageNote": "Uwaga: Jeśli nie widzisz użycia bufora, spróbuj wybrać inny model, a następnie ponownie wybrać żądany model.", + "githubCopilot": { + "accountActions": "Akcje konta GitHub Copilot", + "signIn": "Zaloguj się do GitHub", + "reconnect": "Połącz ponownie z GitHub", + "signedIn": "Zalogowano jako {{account}}", + "connecting": "Łączenie...", + "noModels": "Brak dostępnych modeli Copilot. Zainstaluj lub włącz GitHub Copilot Chat, zaloguj się na uprawnione konto GitHub, a następnie odśwież. Zalogowanie nie potwierdza subskrypcji Copilot. Obowiązują limity planu GitHub, żądań premium i częstotliwości żądań.", + "manageAccount": "Zarządzaj kontem GitHub", + "manageAccountDescription": "Otwiera menu kont VS Code, w którym możesz wylogować się z GitHub. Wylogowanie wpływa na Copilot i inne rozszerzenia korzystające z tego konta." + }, "vscodeLmModel": "Model językowy", "vscodeLmWarning": "Uwaga: Modele dostępne przez interfejs VS Code Language Model API mogą być opakowane lub dostrojone przez dostawcę, dlatego ich działanie może różnić się od bezpośredniego użycia tego samego modelu u typowego dostawcy lub routera. Aby użyć modelu z listy «Language Model», najpierw przełącz się na ten model, a następnie kliknij «Akceptuj» w monicie Copilot Chat; w przeciwnym razie możesz zobaczyć błąd, np. 400 „The requested model is not supported”.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Wyższe wartości sprawiają, że wyjście jest bardziej losowe, niższe wartości czynią je bardziej deterministycznym." }, "modelInfo": { + "imageSupportUnknown": "Obsługa obrazów nie została zgłoszona", + "promptCacheManagedByCopilot": "Buforowaniem promptów zarządza GitHub Copilot", + "promptCacheCopilotDetails": "Buforowanie zależy od modelu i zaplecza GitHub Copilot. Zoo Code nie może skonfigurować czasu przechowywania pamięci podręcznej ani obserwować trafień w pamięci podręcznej przez API VS Code.", "supportsImages": "Obsługuje obrazy", "noImages": "Nie obsługuje obrazów", "supportsPromptCache": "Obsługuje buforowanie podpowiedzi", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 893504febe..e81408cd3b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Ativar cache de prompts", "enablePromptCachingTitle": "Ativar cache de prompts para melhorar o desempenho e reduzir custos para modelos suportados.", "cacheUsageNote": "Nota: Se você não vir o uso do cache, tente selecionar um modelo diferente e depois selecionar novamente o modelo desejado.", + "githubCopilot": { + "accountActions": "Ações da conta do GitHub Copilot", + "signIn": "Entrar no GitHub", + "reconnect": "Reconectar ao GitHub", + "signedIn": "Conectado como {{account}}", + "connecting": "Conectando...", + "noModels": "Nenhum modelo do Copilot disponível. Instale ou ative o GitHub Copilot Chat, entre com uma conta do GitHub elegível e atualize. Entrar não confirma uma assinatura do Copilot. Aplicam-se os limites do seu plano do GitHub, de solicitações premium e de taxa.", + "manageAccount": "Gerenciar conta do GitHub", + "manageAccountDescription": "Abre o menu de contas do VS Code, onde você pode sair do GitHub. Sair afeta o Copilot e outras extensões que usam esta conta." + }, "vscodeLmModel": "Modelo de Linguagem", "vscodeLmWarning": "Observação: Modelos acessados pela VS Code Language Model API podem ser encapsulados ou ajustados pelo provedor, portanto o comportamento pode diferir do uso direto do mesmo modelo em um provedor ou roteador típico. Para usar um modelo no menu suspenso «Language Model», primeiro altere para esse modelo e depois clique em «Aceitar» no prompt do Copilot Chat; caso contrário, você pode ver um erro como 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Valores mais altos tornam a saída mais aleatória, valores mais baixos a tornam mais determinística." }, "modelInfo": { + "imageSupportUnknown": "Suporte a imagens não informado", + "promptCacheManagedByCopilot": "O cache de prompts é gerenciado pelo GitHub Copilot", + "promptCacheCopilotDetails": "O cache depende do modelo e do backend do GitHub Copilot. O Zoo Code não pode configurar a retenção do cache nem observar acertos de cache pela API do VS Code.", "supportsImages": "Suporta imagens", "noImages": "Não suporta imagens", "supportsPromptCache": "Suporta cache de prompts", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 3d55ee12d3..e85867c647 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Включить кэширование подсказок", "enablePromptCachingTitle": "Включить кэширование подсказок для повышения производительности и снижения затрат для поддерживаемых моделей.", "cacheUsageNote": "Примечание: если вы не видите использование кэша, попробуйте выбрать другую модель, а затем вернуться к нужной.", + "githubCopilot": { + "accountActions": "Действия с учётной записью GitHub Copilot", + "signIn": "Войти в GitHub", + "reconnect": "Переподключить GitHub", + "signedIn": "Выполнен вход как {{account}}", + "connecting": "Подключение...", + "noModels": "Модели Copilot недоступны. Установите или включите GitHub Copilot Chat, войдите с подходящей учётной записью GitHub и обновите список. Вход не подтверждает наличие подписки на Copilot. Действуют ограничения вашего тарифа GitHub, премиум-запросов и частоты запросов.", + "manageAccount": "Управление учётной записью GitHub", + "manageAccountDescription": "Открывает меню учётных записей VS Code, где можно выйти из GitHub. Выход затрагивает Copilot и другие расширения, использующие эту учётную запись." + }, "vscodeLmModel": "Языковая модель", "vscodeLmWarning": "Внимание: Модели, доступные через API VS Code Language Model, могут быть обёрнуты или дополнительно дообучены поставщиком, поэтому их поведение может отличаться от прямого использования той же модели у типичного провайдера или роутера. Чтобы использовать модель из выпадающего списка «Language Model», сначала переключитесь на эту модель, затем нажмите «Принять» в запросе Copilot Chat; в противном случае возможна ошибка, например 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Более высокие значения делают ответы более случайными, низкие — более детерминированными." }, "modelInfo": { + "imageSupportUnknown": "Поддержка изображений не сообщена", + "promptCacheManagedByCopilot": "Кэшированием промптов управляет GitHub Copilot", + "promptCacheCopilotDetails": "Кэширование зависит от модели и серверной части GitHub Copilot. Zoo Code не может настроить срок хранения кэша или отслеживать попадания в кэш через API VS Code.", "supportsImages": "Поддерживает изображения", "noImages": "Не поддерживает изображения", "supportsPromptCache": "Поддерживает кэширование подсказок", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5f93ec8035..f350fc3082 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "İstem önbelleğini etkinleştir", "enablePromptCachingTitle": "Desteklenen modeller için performansı artırmak ve maliyetleri azaltmak için istem önbelleğini etkinleştir.", "cacheUsageNote": "Not: Önbellek kullanımını görmüyorsanız, farklı bir model seçip ardından istediğiniz modeli tekrar seçmeyi deneyin.", + "githubCopilot": { + "accountActions": "GitHub Copilot hesap eylemleri", + "signIn": "GitHub'da oturum aç", + "reconnect": "GitHub'a yeniden bağlan", + "signedIn": "{{account}} olarak oturum açıldı", + "connecting": "Bağlanılıyor...", + "noModels": "Kullanılabilir Copilot modeli yok. GitHub Copilot Chat'i yükleyin veya etkinleştirin, uygun bir GitHub hesabıyla oturum açın ve ardından yenileyin. Oturum açmak bir Copilot aboneliğini doğrulamaz. GitHub planınızın, premium isteklerin ve istek hızının sınırları geçerlidir.", + "manageAccount": "GitHub hesabını yönet", + "manageAccountDescription": "VS Code'un hesap menüsünü açar; GitHub oturumunu buradan kapatabilirsiniz. Oturumu kapatmak Copilot'u ve bu hesabı kullanan diğer uzantıları etkiler." + }, "vscodeLmModel": "Dil Modeli", "vscodeLmWarning": "Not: VS Code Language Model API üzerinden erişilen modeller sağlayıcı tarafından sarılmış veya ince ayarlanmış olabilir; bu nedenle davranış, aynı modelin tipik bir sağlayıcı ya da yönlendirici üzerinden doğrudan kullanılmasından farklı olabilir. «Language Model» açılır menüsünden bir model kullanmak için önce o modele geçin ve ardından Copilot Chat isteminde «Kabul Et»e tıklayın; aksi takdirde 400 «The requested model is not supported» gibi bir hata görebilirsiniz.", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Daha yüksek değerler çıktıyı daha rastgele yapar, daha düşük değerler daha deterministik hale getirir." }, "modelInfo": { + "imageSupportUnknown": "Görsel desteği bildirilmedi", + "promptCacheManagedByCopilot": "İstem önbelleğini GitHub Copilot yönetir", + "promptCacheCopilotDetails": "Önbellekleme modele ve GitHub Copilot arka ucuna bağlıdır. Zoo Code, VS Code API'si üzerinden önbellek saklama süresini yapılandıramaz veya önbellek isabetlerini gözlemleyemez.", "supportsImages": "Görüntüleri destekler", "noImages": "Görüntüleri desteklemez", "supportsPromptCache": "İstem önbelleğini destekler", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 5ec6e8c105..cce847c8e3 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "Bật bộ nhớ đệm lời nhắc", "enablePromptCachingTitle": "Bật bộ nhớ đệm lời nhắc để cải thiện hiệu suất và giảm chi phí cho các mô hình được hỗ trợ.", "cacheUsageNote": "Lưu ý: Nếu bạn không thấy việc sử dụng bộ nhớ đệm, hãy thử chọn một mô hình khác và sau đó chọn lại mô hình mong muốn của bạn.", + "githubCopilot": { + "accountActions": "Thao tác tài khoản GitHub Copilot", + "signIn": "Đăng nhập GitHub", + "reconnect": "Kết nối lại GitHub", + "signedIn": "Đã đăng nhập với tên {{account}}", + "connecting": "Đang kết nối...", + "noModels": "Không có mô hình Copilot nào khả dụng. Hãy cài đặt hoặc bật GitHub Copilot Chat, đăng nhập bằng tài khoản GitHub đủ điều kiện, rồi làm mới. Đăng nhập không xác nhận bạn có gói đăng ký Copilot. Áp dụng giới hạn của gói GitHub, yêu cầu cao cấp và tần suất yêu cầu.", + "manageAccount": "Quản lý tài khoản GitHub", + "manageAccountDescription": "Mở menu tài khoản của VS Code, nơi bạn có thể đăng xuất khỏi GitHub. Đăng xuất ảnh hưởng đến Copilot và các tiện ích mở rộng khác dùng tài khoản này." + }, "vscodeLmModel": "Mô hình ngôn ngữ", "vscodeLmWarning": "Lưu ý: Các mô hình truy cập qua VS Code Language Model API có thể được nhà cung cấp bao bọc hoặc tinh chỉnh, vì vậy hành vi có thể khác so với khi dùng trực tiếp cùng mô hình từ nhà cung cấp hoặc router thông thường. Để dùng một mô hình trong menu «Language Model», trước tiên hãy chuyển sang mô hình đó rồi nhấp «Chấp nhận» trong lời nhắc Copilot Chat; nếu không bạn có thể gặp lỗi như 400 «The requested model is not supported».", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "Giá trị cao hơn làm cho đầu ra ngẫu nhiên hơn, giá trị thấp hơn làm cho nó xác định hơn." }, "modelInfo": { + "imageSupportUnknown": "Chưa báo cáo khả năng hỗ trợ hình ảnh", + "promptCacheManagedByCopilot": "Bộ nhớ đệm prompt do GitHub Copilot quản lý", + "promptCacheCopilotDetails": "Bộ nhớ đệm phụ thuộc vào mô hình và backend của GitHub Copilot. Zoo Code không thể cấu hình thời gian lưu bộ nhớ đệm hay quan sát lượt trúng bộ nhớ đệm qua API của VS Code.", "supportsImages": "Hỗ trợ hình ảnh", "noImages": "Không hỗ trợ hình ảnh", "supportsPromptCache": "Hỗ trợ bộ nhớ đệm lời nhắc", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9e0a94d908..8813225674 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -579,6 +579,16 @@ "enablePromptCaching": "启用提示缓存", "enablePromptCachingTitle": "开启提示缓存可提升性能并节省成本", "cacheUsageNote": "提示:若未显示缓存使用情况,请切换模型后重新选择", + "githubCopilot": { + "accountActions": "GitHub Copilot 账户操作", + "signIn": "登录 GitHub", + "reconnect": "重新连接 GitHub", + "signedIn": "已以 {{account}} 身份登录", + "connecting": "正在连接...", + "noModels": "没有可用的 Copilot 模型。请安装或启用 GitHub Copilot Chat,使用符合条件的 GitHub 账户登录,然后刷新。登录并不能确认你拥有 Copilot 订阅。GitHub 套餐、高级请求和速率限制均适用。", + "manageAccount": "管理 GitHub 账户", + "manageAccountDescription": "打开 VS Code 的账户菜单,你可以在其中退出 GitHub 登录。退出登录会影响 Copilot 以及使用该账户的其他扩展。" + }, "vscodeLmModel": "VSCode LM 模型", "vscodeLmWarning": "注意:通过 VS Code Language Model API 访问的模型可能由提供商进行封装或微调,因此其行为可能与直接从常见提供商或路由器使用同一模型时不同。要使用「Language Model」下拉列表中的模型,请先切换到该模型,然后在 Copilot Chat 提示中点击「接受」;否则可能会出现 400「The requested model is not supported」等错误。", "googleCloudSetup": { @@ -1002,6 +1012,9 @@ "rangeDescription": "值越高回答越多样,值越低越保守" }, "modelInfo": { + "imageSupportUnknown": "未报告图像支持情况", + "promptCacheManagedByCopilot": "提示词缓存由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "缓存行为取决于模型和 GitHub Copilot 后端。Zoo Code 无法通过 VS Code API 配置缓存保留时间,也无法观察缓存命中情况。", "supportsImages": "支持图像", "noImages": "不支持图像", "supportsPromptCache": "支持提示缓存", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c2fd65c755..f648e733e1 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -606,6 +606,16 @@ "enablePromptCaching": "啟用提示快取", "enablePromptCachingTitle": "啟用提示快取以提升支援的模型效能並降低成本。", "cacheUsageNote": "注意:如果您沒有看到快取使用情況,請嘗試選擇其他模型,然後重新選擇您想要的模型。", + "githubCopilot": { + "accountActions": "GitHub Copilot 帳戶動作", + "signIn": "登入 GitHub", + "reconnect": "重新連線 GitHub", + "signedIn": "已以 {{account}} 身分登入", + "connecting": "連線中...", + "noModels": "沒有可用的 Copilot 模型。請安裝或啟用 GitHub Copilot Chat,使用符合資格的 GitHub 帳戶登入,然後重新整理。登入並不能確認你擁有 Copilot 訂閱。GitHub 方案、進階要求與速率限制皆適用。", + "manageAccount": "管理 GitHub 帳戶", + "manageAccountDescription": "開啟 VS Code 的帳戶選單,你可以在其中登出 GitHub。登出會影響 Copilot 以及使用此帳戶的其他擴充功能。" + }, "vscodeLmModel": "語言模型", "vscodeLmWarning": "注意:透過 VS Code Language Model API 存取的模型可能由供應商封裝或微調,因此其行為可能與直接從一般供應商或路由器使用相同模型時不同。要使用「Language Model」下拉式選單中的模型,請先切換到該模型,然後在 Copilot Chat 提示中點選「接受」;否則可能會出現 400「The requested model is not supported」等錯誤。", "googleCloudSetup": { @@ -1029,6 +1039,9 @@ "rangeDescription": "較高值使輸出更隨機,較低值更確定" }, "modelInfo": { + "imageSupportUnknown": "未回報圖片支援情況", + "promptCacheManagedByCopilot": "提示詞快取由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "快取行為取決於模型與 GitHub Copilot 後端。Zoo Code 無法透過 VS Code API 設定快取保留時間,也無法觀察快取命中情況。", "supportsImages": "支援影像", "noImages": "不支援影像", "supportsPromptCache": "支援提示快取", diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3e32116374..18d4a78f22 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -8,6 +8,7 @@ import { isDynamicProvider, parseOpenAiExtraBody, providerIdentifiers, + githubCopilotLanguageModel, } from "@roo-code/types" type OpenAiExtraBodyParseResult = ReturnType @@ -147,6 +148,14 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.modelSelector") } break + case providerIdentifiers.githubCopilot: + if ( + apiConfiguration.vsCodeLmModelSelector?.vendor !== githubCopilotLanguageModel.vendor || + (!apiConfiguration.vsCodeLmModelSelector.id && !apiConfiguration.vsCodeLmModelSelector.family) + ) { + return i18next.t("settings:validation.modelSelector") + } + break case providerIdentifiers.fireworks: if (!apiConfiguration.fireworksApiKey) { return i18next.t("settings:validation.apiKey") From 59fcf6eee3e8cc290e0f87beac17f6fe32f14a06 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 13:07:23 -0700 Subject: [PATCH 02/33] chore: retrigger CI From 83bf0d910b424b9fdcd08986d9fb6619bb121a06 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 14:23:02 -0700 Subject: [PATCH 03/33] test(api): expand GitHub Copilot provider coverage --- .../__tests__/github-copilot.spec.ts | 71 +++++++++++++++++ src/api/providers/__tests__/vscode-lm.spec.ts | 22 ++++++ .../__tests__/vscode-lm-format.spec.ts | 26 +++++++ .../__tests__/vscode-lm-image-part.spec.ts | 53 +++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 42 ++++++++++ .../__tests__/copilotModelBroadcast.spec.ts | 53 ++++++++++++- .../ApiOptions.interactions.spec.tsx | 8 ++ .../settings/__tests__/ModelInfoView.spec.tsx | 12 +++ .../__tests__/ProviderRouting.spec.tsx | 78 +++++++++++++++++++ .../hooks/__tests__/useSelectedModel.spec.ts | 21 +++++ .../src/utils/__tests__/validate.spec.ts | 20 +++++ 11 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/api/transform/__tests__/vscode-lm-image-part.spec.ts diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index ca202065ae..858cf0fc82 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -98,6 +98,7 @@ import { import { getVsCodeLmModelInfo } from "../vscode-lm-capabilities" import type { ApiHandlerOptions } from "../../../shared/api" import { clearAllMocks } from "../../../test-utils/reset" +import { checkContextWindowExceededError } from "../../../core/context/context-management/context-error-handling" import { collectStream } from "../../../test-utils/stream" /** The stable typings this extension builds against predate image parts, so reach them by name. */ @@ -441,4 +442,74 @@ describe("GitHubCopilotHandler", () => { copilotHandler.dispose() } }) + + describe("input limits", () => { + const copilotModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + vendor: "copilot", + id: "limited", + family: "limited-family", + maxInputTokens: 16000, + ...overrides, + }) + + const run = async (perMessageTokens: number) => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([copilotModel()] as never) + mockLanguageModelChat.countTokens.mockResolvedValue(perMessageTokens) + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + })(), + })) + const handler = new GitHubCopilotHandler({}) + try { + await collectStream(handler.createMessage("system", [{ role: "user", content: "hello" }])) + } finally { + handler.dispose() + } + } + + it("refuses a request measured over the model's window, as an error Zoo recovers from by condensing", async () => { + const failure = await run(9000).catch((error: unknown) => error) + + expect(checkContextWindowExceededError(failure)).toBe(true) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("admits a request that exactly fills the window", async () => { + // System prompt and user message are counted separately: 2 x 8000 = 16000. + await expect(run(8000)).resolves.toBeUndefined() + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalledTimes(1) + }) + }) + + describe("countTokens", () => { + beforeEach(() => mockLanguageModelChat.countTokens.mockResolvedValue(42)) + + it("measures the content as a user message through the host's counter", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + { ...mockLanguageModelChat, vendor: "copilot" }, + ] as never) + const handler = new GitHubCopilotHandler({}) + try { + await expect(handler.countTokens([{ type: "text", text: "hello" }])).resolves.toBe(42) + expect(mockLanguageModelChat.countTokens).toHaveBeenCalledTimes(1) + expect(mockLanguageModelChat.countTokens.mock.calls[0][0]).toMatchObject({ role: "user" }) + } finally { + handler.dispose() + } + }) + + it("fails clearly, rather than returning a made-up count, when no Copilot model is available", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + const handler = new GitHubCopilotHandler({}) + try { + await expect(handler.countTokens([{ type: "text", text: "hello" }])).rejects.toThrow( + "No matching GitHub Copilot model", + ) + } finally { + handler.dispose() + } + }) + }) }) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 429f287ab4..c65861fb34 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -191,6 +191,28 @@ describe("VsCodeLmHandler", () => { expect(client.vendor).toBe("vscode") }) + it("gives the placeholder client a working stream, text and token counter", async () => { + ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([]) + const client = await handler["createClient"]({}) + + const response = await client.sendRequest([], {}, new vscode.CancellationTokenSource().token) + const streamed: unknown[] = [] + for await (const part of response.stream) streamed.push(part) + const text: string[] = [] + for await (const chunk of response.text) text.push(chunk) + + expect(streamed).toHaveLength(1) + expect(streamed[0]).toBeInstanceOf(vscode.LanguageModelTextPart) + expect(text.join("")).toContain("functionality is limited") + await expect(client.countTokens("anything")).resolves.toBe(0) + }) + + it("counts nothing for input that is neither text nor a chat message", async () => { + handler["client"] = mockLanguageModelChat + await expect(handler["internalCountTokens"]({} as never)).resolves.toBe(0) + expect(mockLanguageModelChat.countTokens).not.toHaveBeenCalled() + }) + it("should throw a Zoo Code branded error when selectChatModels fails", async () => { ;(vscode.lm.selectChatModels as Mock).mockRejectedValueOnce(new Error("network down")) diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index 368cf8f123..f3dc99a4a7 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -123,6 +123,32 @@ describe("convertToVsCodeLmMessages", () => { expect(result[0].content[0]).toEqual({ data: Buffer.from("image"), mimeType: "image/png" }) }) + it("refuses an image on a host that cannot carry image data, instead of dropping it silently", () => { + const hostImagePart = Reflect.get(vscode, "LanguageModelDataPart") + const factory = hostImagePart.image + Object.assign(hostImagePart, { image: undefined }) + try { + expect(() => + convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + }, + ], + }, + ], + true, + ), + ).toThrow("Image input requires a newer version of VS Code") + } finally { + Object.assign(hostImagePart, { image: factory }) + } + }) + it("preserves image bytes within tool results", () => { const result = convertToVsCodeLmMessages( [ diff --git a/src/api/transform/__tests__/vscode-lm-image-part.spec.ts b/src/api/transform/__tests__/vscode-lm-image-part.spec.ts new file mode 100644 index 0000000000..4c964862b2 --- /dev/null +++ b/src/api/transform/__tests__/vscode-lm-image-part.spec.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode" + +import { canCreateImageParts, createImagePart } from "../vscode-lm-image-part" + +vi.mock("vscode", () => ({ LanguageModelChatMessage: { User: vi.fn() }, LanguageModelDataPart: undefined })) + +// The stable typings predate image parts, so tests install and remove the host's class by name. +const setHostImagePart = (value: unknown) => Object.assign(vscode, { LanguageModelDataPart: value }) + +describe("image part support detection", () => { + afterEach(() => setHostImagePart(undefined)) + + it("builds image parts through the host's factory", () => { + const image = vi.fn((data: Uint8Array, mimeType: string) => ({ data, mimeType })) + setHostImagePart( + class { + static image = image + }, + ) + const bytes = new Uint8Array([1, 2]) + + expect(canCreateImageParts()).toBe(true) + expect(createImagePart(bytes, "image/png")).toEqual({ data: bytes, mimeType: "image/png" }) + expect(image).toHaveBeenCalledWith(bytes, "image/png") + }) + + it.each([ + ["a host without image parts", undefined], + ["a host whose image part has no factory", class {}], + ["a host that exposes something other than a class", { image: () => ({}) }], + [ + "a host whose factory is not callable", + class { + static image = "nope" + }, + ], + ])("cannot create image parts on %s", (_label, hostValue) => { + setHostImagePart(hostValue) + + expect(canCreateImageParts()).toBe(false) + expect(createImagePart(new Uint8Array(), "image/png")).toBeUndefined() + }) + + it("re-checks the host on every call, since the host and not this bundle decides support", () => { + expect(canCreateImageParts()).toBe(false) + setHostImagePart( + class { + static image = () => ({}) + }, + ) + expect(canCreateImageParts()).toBe(true) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index f903859657..b8127abda0 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -200,6 +200,48 @@ describe("webviewMessageHandler - Copilot authentication", () => { expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("command missing")) }) + it("reports a non-Error sign-in failure as text", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockRejectedValue("denied") + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + error: "denied", + }) + }) + + it("logs a non-Error failure to open account management", async () => { + vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockRejectedValue("no such command") + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("no such command")) + }) + + describe("legacy VS Code LM model requests", () => { + it("lists every model unscoped and never looks up a GitHub account", async () => { + const account = vi.spyOn(copilotProvider, "getGitHubCopilotAccount") + const models = vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) + + expect(models).toHaveBeenCalledWith({}) + expect(account).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + vsCodeLmModels: [], + }) + }) + + it("reports a failed lookup to the legacy list without touching the Copilot one", async () => { + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue("host busy") + + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + error: "host busy", + }) + }) + }) + it("restores a signed-in account during passive model refresh", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts index 2698996bdc..78121e50ea 100644 --- a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -1,10 +1,11 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" import { registerCopilotModelBroadcast, type CopilotModelBroadcastTarget } from "../copilotModelBroadcast" -vi.mock("vscode", () => ({ lm: {} })) +vi.mock("vscode", () => ({ lm: { onDidChangeChatModels: vi.fn() } })) vi.mock("../../../api/providers/vscode-lm", () => ({ getVsCodeLmModels: vi.fn() })) type Models = Awaited> @@ -163,4 +164,54 @@ describe("registerCopilotModelBroadcast", () => { }) expect(() => registration.dispose()).not.toThrow() }) + + it("logs a failure that is not an Error as text", async () => { + const { notify, log } = setup({ fetchModels: vi.fn().mockRejectedValue("plain failure") }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: plain failure") + }) + + describe("default host wiring", () => { + const hostEvent = vi.mocked(vscode.lm.onDidChangeChatModels) + + it("listens to the host's model-change event and releases it on dispose", () => { + const hostSubscription = { dispose: vi.fn() } + hostEvent.mockReturnValue(hostSubscription as never) + + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) + expect(hostEvent).toHaveBeenCalledWith(expect.any(Function)) + + registration.dispose() + expect(hostSubscription.dispose).toHaveBeenCalledTimes(1) + }) + + it("refreshes when the host raises the event", async () => { + let raise: () => void = () => {} + hostEvent.mockImplementation(((listener: () => void) => { + raise = listener + return { dispose: vi.fn() } + }) as never) + const fetchModels = vi.fn().mockResolvedValue([]) + + registerCopilotModelBroadcast({ + getTargets: () => [], + log: vi.fn(), + fetchModels: fetchModels as never, + debounceMs: 10, + }) + raise() + await vi.advanceTimersByTimeAsync(10) + + expect(fetchModels).toHaveBeenCalledTimes(1) + }) + + it("tolerates a host that does not expose the event", () => { + hostEvent.mockReturnValue(undefined as never) + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) + expect(() => registration.dispose()).not.toThrow() + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx index d58b455d66..b6955c703a 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -242,6 +242,14 @@ describe("ApiOptions interactions", () => { configuration: {}, expectedMessage: { type: "requestVsCodeLmModels" }, }, + { + provider: providerIdentifiers.githubCopilot, + configuration: {}, + expectedMessage: { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }, + }, { provider: providerIdentifiers.litellm, configuration: { litellmBaseUrl: "http://litellm:4000", litellmApiKey: "litellm-key" }, diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx index cc20e1eb34..1dce6ff5bb 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -48,6 +48,18 @@ const getPricingRowValues = (tier: string) => { } describe("ModelInfoView service tier pricing", () => { + it("reports prompt caching as unsupported for other providers when the model says nothing about it", () => { + render( + , + ) + expect(screen.getByText("Does not support prompt caching")).toBeInTheDocument() + }) + it("shows unknown Copilot image capability without claiming the model is text-only", () => { render( { expect(props.displayTransform?.({ vendor: model.vendor, family: model.family, id: model.id })).toBe(model.id) }) + describe("model picker wiring", () => { + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + const pickerProps = () => modelPickerMock.mock.lastCall![0] + const mount = (provider: typeof providerIdentifiers.githubCopilot | typeof providerIdentifiers.vscodeLm) => + renderWithQuery( + , + ) + + const listed = [ + { id: "m1", vendor: "copilot", family: "fam", version: "1" }, + { vendor: "other", family: "no-id" }, + ] + + it("lists the legacy provider's models from its own messages and names its service accordingly", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + + expect(pickerProps().serviceName).toBe("VS Code LM") + expect(Object.keys(pickerProps().models ?? {})).toEqual(["m1", "other/no-id"]) + }) + + it("keeps each provider's list separate: the legacy picker ignores Copilot messages", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: listed }) + + expect(modelPickerMock).not.toHaveBeenCalled() + }) + + it("ignores a legacy list that arrives with an error", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed, error: "partial" }) + + expect(modelPickerMock).not.toHaveBeenCalled() + }) + + describe("stored value mapping", () => { + beforeEach(() => { + mount(providerIdentifiers.vscodeLm) + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + }) + + it("stores a listed model by its identity, with no capability snapshot", () => { + expect(pickerProps().valueTransform?.("m1")).toEqual({ + id: "m1", + vendor: "copilot", + family: "fam", + version: "1", + }) + }) + + it("stores an unlisted value as vendor and family", () => { + expect(pickerProps().valueTransform?.("acme/model-x")).toEqual({ vendor: "acme", family: "model-x" }) + }) + + it.each([ + ["nothing stored", undefined, ""], + ["a stored id that is listed", { id: "m1" }, "m1"], + ["a stored vendor and family whose model has an id", { vendor: "copilot", family: "fam" }, "m1"], + [ + "a stored vendor and family whose model has no id", + { vendor: "other", family: "no-id" }, + "other/no-id", + ], + [ + "a stored vendor and family that is no longer listed", + { vendor: "gone", family: "model" }, + "gone/model", + ], + ["a stored selector too incomplete to name a model", { vendor: "copilot" }, ""], + ])("shows %s as the right label", (_label, stored, expected) => { + expect(pickerProps().displayTransform?.(stored)).toBe(expected) + }) + }) + }) + it("keeps the legacy VS Code LM provider free of Copilot login controls", () => { renderWithQuery( { ).toBe(false) }) + it("finds the reported model by family when the saved selector has no id", () => { + reportedModels( + { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, + { + id: "by-family", + family: "wanted-family", + modelInfo: { contextWindow: 555, supportsPromptCache: false }, + }, + ) + + const selected = selectCopilot({ vendor: "copilot", family: "wanted-family" }) + + expect(selected.info?.contextWindow).toBe(555) + expect(selected.id).toBe("copilot/wanted-family") + }) + + it("falls back to the default model id when nothing is selected yet", () => { + const { result } = renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.githubCopilot })) + expect(result.current.id).toBe(vscodeLlmDefaultModelId) + }) + it("picks the reported model matching the saved id, not merely the first one", () => { reportedModels( { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index a1936b89d7..7d089ecaa3 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -178,6 +178,26 @@ describe("Model Validation Functions", () => { }) }) + describe("validateApiConfiguration for GitHub Copilot", () => { + const validate = (vsCodeLmModelSelector: ProviderSettings["vsCodeLmModelSelector"]) => + validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector }) + + it.each([ + ["a model id", { vendor: "copilot", id: "gpt-x" }], + ["a model family", { vendor: "copilot", family: "gpt-x" }], + ])("accepts a selection that names %s", (_label, selector) => { + expect(validate(selector)).toBeUndefined() + }) + + it.each([ + ["no selection", undefined], + ["a selection from another vendor", { vendor: "other", id: "gpt-x" }], + ["a Copilot selection naming neither id nor family", { vendor: "copilot" }], + ])("requires a model for %s", (_label, selector) => { + expect(validate(selector)).toBe("settings:validation.modelSelector") + }) + }) + describe("validateApiConfigurationExcludingModelErrors", () => { it("returns undefined when configuration is valid", () => { const config: ProviderSettings = { From 4d06cfea000ff176b3db25a4e89435e795ac3e5c Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 15:01:43 -0700 Subject: [PATCH 04/33] test(webview): cover remaining Copilot message handler paths --- .../__tests__/webviewMessageHandler.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index b8127abda0..cbde7d33c5 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -257,6 +257,20 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + it("reports no account as null, so the webview clears a stale sign-in rather than keeping it", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: null, + vsCodeLmModels: [], + }) + }) + it("shows model discovery errors instead of silently returning an empty list", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue(new Error("Model access denied")) From ab0e7ea7b3f4a68f9e6f5dc2af6c7aaee265db30 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 18:18:58 -0700 Subject: [PATCH 05/33] fix(api): address review feedback on GitHub Copilot provider Key in-flight sign-ins by forceNewSession so Reconnect is not answered by a plain sign-in. Cap the pre-report context window at the enforced input limit. Treat vision as unreported unless the host reports it. Drop the unenforceable per-model tool threshold. Add a visual snapshot for the provider settings. --- README.md | 17 +++-- packages/types/src/mcp.ts | 6 -- packages/types/src/model.ts | 1 - .../__tests__/github-copilot.spec.ts | 63 ++++++++++++++++-- src/api/providers/github-copilot.ts | 19 ++++-- src/api/providers/vscode-lm-capabilities.ts | 8 +-- src/core/task/Task.ts | 7 +- src/core/task/__tests__/Task.spec.ts | 40 ----------- webview-ui/playwright/gallery/stories.tsx | 28 ++++++++ .../components/chat/TooManyToolsWarning.tsx | 5 +- .../__tests__/TooManyToolsWarning.spec.tsx | 36 ---------- .../GitHubCopilotSettings.visual.tsx | 62 +++++++++++++++++ .../github-copilot-error-dark.png | Bin 0 -> 29783 bytes .../github-copilot-no-models-dark.png | Bin 0 -> 28804 bytes .../github-copilot-signed-in-dark.png | Bin 0 -> 27488 bytes .../__tests__/ProviderRouting.spec.tsx | 2 +- .../hooks/__tests__/useSelectedModel.spec.ts | 35 +++++++++- .../components/ui/hooks/useSelectedModel.ts | 4 ++ webview-ui/src/hooks/useTooManyTools.ts | 13 ++-- 19 files changed, 219 insertions(+), 127 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png diff --git a/README.md b/README.md index 079164b2fc..9ee2853848 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,15 @@ Capabilities come from what VS Code reports for each Copilot model, so new model changes, and nothing about a model is saved into your settings where it could go stale. - **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one - is known. -- **Images:** read from the host's reported capability, falling back to Zoo Code's curated catalog - only when the host reports nothing. A capability that neither states is shown as **Image support - not reported** rather than assumed unsupported. Images are sent as image data, not text - placeholders, and need a VS Code version that supports image parts; Zoo Code's minimum supported - VS Code version stays 1.100. + is known. Before the host reports, the curated limit is shown, matching what Zoo Code enforces. +- **Images:** read from the capability the host reports for each live model. A model that reports + nothing shows **Image support not reported** instead of being assumed unsupported or borrowed from + Zoo Code's curated catalog, which only describes a model the host has not listed yet. Images are + sent as image data, not text placeholders, and need a VS Code version that supports image parts; + Zoo Code's minimum supported VS Code version stays 1.100. - **Tools:** VS Code tells extensions only whether a model supports tools, not how many it accepts, - so Zoo Code cannot enforce a per-model limit. The same advisory shown for any provider applies when - many MCP tools are enabled. GitHub plan limits, premium-request allowances, organization policies, - and backend limits still apply. + so Zoo Code applies the same advisory it shows for any provider when many MCP tools are enabled. + GitHub plan limits, premium-request allowances, organization policies, and backend limits still apply. - **Prompt caching:** Copilot owns any backend caching. The public VS Code Language Model API exposes no cache-control directives or cache-hit usage, so Zoo Code neither enables caching nor reports savings; the provider-managed label states this and does not promise a cache hit. diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts index 8cd5d8c34d..ce23cadd32 100644 --- a/packages/types/src/mcp.ts +++ b/packages/types/src/mcp.ts @@ -6,12 +6,6 @@ import { z } from "zod" */ export const MAX_MCP_TOOLS_THRESHOLD = 60 -/** A provider-supplied `maxTools` replaces the generic advisory, so the warning tracks a real limit. */ -export function getMcpToolThreshold(modelInfo?: { maxTools?: number }): number { - const limit = modelInfo?.maxTools - return limit !== undefined && Number.isSafeInteger(limit) && limit > 0 ? limit : MAX_MCP_TOOLS_THRESHOLD -} - /** * McpServerUse */ diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 1b3b1250b2..3f4081e78d 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -102,7 +102,6 @@ export const isModelParameter = (value: string): value is ModelParameter => export const modelInfoSchema = z.object({ maxTokens: z.number().nullish(), maxThinkingTokens: z.number().nullish(), - maxTools: z.number().int().positive().optional(), contextWindow: z.number(), supportsImages: z.boolean().optional(), supportsPromptCache: z.boolean(), diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 858cf0fc82..ba40d3059b 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -217,6 +217,60 @@ describe("connectGitHubCopilot", () => { expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) }) + describe("overlapping sign-in requests with different intent", () => { + const session = (label: string) => ({ id: "s", accessToken: "t", scopes: [], account: { id: "a", label } }) + const pendingSession = () => { + let finish: (value: ReturnType) => void = () => {} + const promise = new Promise((resolve) => (finish = resolve)) as never + return { promise, finish: (label: string) => finish(session(label)) } + } + + it("gives a Reconnect its own forced session instead of the pending plain sign-in's", async () => { + const plain = pendingSession() + const fresh = pendingSession() + vi.mocked(vscode.authentication.getSession) + .mockImplementationOnce(() => plain.promise) + .mockImplementationOnce(() => fresh.promise) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + const onFresh = vi.fn() + + const first = connectGitHubCopilot() + const second = connectGitHubCopilot(onFresh, true) + plain.finish("Plain User") + fresh.finish("Fresh User") + + await expect(first).resolves.toMatchObject({ account: "Plain User" }) + await expect(second).resolves.toMatchObject({ account: "Fresh User" }) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(2) + expect(vscode.authentication.getSession).toHaveBeenLastCalledWith("github", ["user:email"], { + forceNewSession: true, + clearSessionPreference: true, + }) + expect(onFresh).toHaveBeenCalledWith("Fresh User") + }) + + it("still shares one attempt between concurrent Reconnect requests", async () => { + const fresh = pendingSession() + vi.mocked(vscode.authentication.getSession).mockImplementationOnce(() => fresh.promise) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const results = [connectGitHubCopilot(undefined, true), connectGitHubCopilot(undefined, true)] + fresh.finish("Fresh User") + + await Promise.all(results) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + it("lets a failed attempt of one kind be retried without disturbing the other", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("cancelled")) + await expect(connectGitHubCopilot(undefined, true)).rejects.toThrow("cancelled") + + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce(session("Retry User")) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await expect(connectGitHubCopilot(undefined, true)).resolves.toMatchObject({ account: "Retry User" }) + }) + }) + it("allows a new sign-in once the previous one has settled", async () => { vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("Sign-in cancelled")) await expect(connectGitHubCopilot()).rejects.toThrow("Sign-in cancelled") @@ -306,8 +360,8 @@ describe("Copilot model capabilities", () => { expect(getVsCodeLmModelInfo(model).supportsImages).toBe(false) }) - it("falls back to the curated catalog only when the host reports nothing", () => { - expect(getVsCodeLmModelInfo(liveModel({ family: curatedFamily })).supportsImages).toBe(true) + it("does not borrow the catalog's vision flag for a live model the host reports nothing about", () => { + expect(getVsCodeLmModelInfo(liveModel({ family: curatedFamily })).supportsImages).toBeUndefined() }) it("leaves vision unset rather than guessing when no source states it", () => { @@ -337,11 +391,6 @@ describe("Copilot model capabilities", () => { handler.dispose() } }) - - it("does not set a tool-count limit, since consumers are only told whether tools are supported", () => { - const info = getVsCodeLmModelInfo(liveModel({ capabilities: { supportsToolCalling: true } })) - expect(info.maxTools).toBeUndefined() - }) }) describe("context window", () => { diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts index 471bff144c..7ab4860561 100644 --- a/src/api/providers/github-copilot.ts +++ b/src/api/providers/github-copilot.ts @@ -93,17 +93,23 @@ function getGitHubSession(options: vscode.AuthenticationGetSessionOptions) { ) } -let signInInFlight: Promise<{ account: string; models: Awaited> }> | undefined +type SignInResult = { account: string; models: Awaited> } + +// Keyed by whether a fresh session was requested, so a Reconnect is never answered by a plain sign-in. +const signInsInFlight = new Map>() /** - * Signs in to GitHub and lists the Copilot models that become available. Concurrent calls share one - * attempt, so a double click cannot open two sign-in prompts. + * Signs in to GitHub and lists the Copilot models that become available. Concurrent calls with the + * same `forceNewSession` share one attempt, so a double click cannot open two sign-in prompts. * * Authenticating does not prove a Copilot entitlement: an account without one signs in successfully * and simply sees no models, which callers must present as such. */ export function connectGitHubCopilot(onAuthenticated?: (account: string) => Promise, forceNewSession = false) { - signInInFlight ??= (async () => { + const pending = signInsInFlight.get(forceNewSession) + if (pending) return pending + + const attempt = (async (): Promise => { try { const { authProviderId, authScopeSets, chatExtensionId, selector } = githubCopilotLanguageModel const session = await vscode.authentication.getSession( @@ -116,10 +122,11 @@ export function connectGitHubCopilot(onAuthenticated?: (account: string) => Prom await vscode.extensions.getExtension(chatExtensionId)?.activate() return { account: session.account.label, models: await getVsCodeLmModels(selector) } } finally { - signInInFlight = undefined + signInsInFlight.delete(forceNewSession) } })() - return signInInFlight + signInsInFlight.set(forceNewSession, attempt) + return attempt } /** The signed-in account, or undefined when there is none or the lookup is not possible. */ diff --git a/src/api/providers/vscode-lm-capabilities.ts b/src/api/providers/vscode-lm-capabilities.ts index fa3e2cf3a5..4bc795ac38 100644 --- a/src/api/providers/vscode-lm-capabilities.ts +++ b/src/api/providers/vscode-lm-capabilities.ts @@ -32,8 +32,8 @@ export function reportedImageSupport(model: vscode.LanguageModelChat): boolean | } /** - * Derives Zoo's model info from what VS Code reports for a live model. Reported values win; the - * curated catalog only fills gaps, and anything neither source states stays unset rather than guessed. + * Derives Zoo's model info from what VS Code reports for a live model. The curated catalog only fills + * gaps in everything but vision, and anything neither source states stays unset rather than guessed. */ export function getVsCodeLmModelInfo(model: vscode.LanguageModelChat): ModelInfo { const curated = vscodeLlmModels[model.family as keyof typeof vscodeLlmModels] @@ -48,8 +48,8 @@ export function getVsCodeLmModelInfo(model: vscode.LanguageModelChat): ModelInfo ...curated, contextWindow: curatedInputLimit && curatedInputLimit > 0 ? Math.min(liveInputLimit, curatedInputLimit) : liveInputLimit, - // Without image-part support in the host, no image can be sent whatever the model advertises. - supportsImages: canCreateImageParts() ? (reportedImageSupport(model) ?? curated?.supportsImages) : false, + // A live model's vision is the host's word alone; unreported stays unset rather than borrowing the catalog's. + supportsImages: canCreateImageParts() ? reportedImageSupport(model) : false, supportsPromptCache: false, inputPrice: 0, outputPrice: 0, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d0e6febe5c..fdcc740998 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -54,7 +54,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, - getMcpToolThreshold, + MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, providerIdentifiers, } from "@roo-code/types" @@ -2974,14 +2974,13 @@ export class Task extends EventEmitter implements TaskLike { // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() - const threshold = getMcpToolThreshold(this.api.getModel().info) - if (enabledToolCount > threshold) { + if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { await this.say( "too_many_tools_warning", JSON.stringify({ toolCount: enabledToolCount, serverCount: enabledServerCount, - threshold, + threshold: MAX_MCP_TOOLS_THRESHOLD, }), undefined, undefined, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index ab545b223d..85325b39ff 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -11,7 +11,6 @@ import type { Mock } from "vitest" import { providerIdentifiers, - MAX_MCP_TOOLS_THRESHOLD, RooCodeEventName, type ClineMessage, type GlobalState, @@ -6461,45 +6460,6 @@ describe("Cline", () => { }) }) - describe("startTask MCP tool warning", () => { - const startWith = async (enabledToolCount: number, maxTools?: number) => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "new task", - startTask: false, - }) - const taskAccess = getTaskTestAccess(task) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) - vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ - enabledToolCount, - enabledServerCount: 1, - }) - vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "test-model", - info: { contextWindow: 1000, supportsPromptCache: false, ...(maxTools ? { maxTools } : {}) }, - }) - await taskAccess.startTask("new task") - return saySpy.mock.calls.find(([type]) => type === "too_many_tools_warning") - } - - it("keeps the advisory warning for providers that report no tool limit", async () => { - const warning = await startWith(MAX_MCP_TOOLS_THRESHOLD + 1) - expect(JSON.parse(warning![1] as string)).toMatchObject({ threshold: MAX_MCP_TOOLS_THRESHOLD }) - }) - - it("does not warn at or below the advisory threshold", async () => { - expect(await startWith(MAX_MCP_TOOLS_THRESHOLD)).toBeUndefined() - }) - - it("lets a provider-reported limit replace the advisory in both directions", async () => { - expect(await startWith(130, 200)).toBeUndefined() - const warning = await startWith(21, 20) - expect(JSON.parse(warning![1] as string)).toMatchObject({ threshold: 20 }) - }) - }) - describe("startTask", () => { it("posts a clean state immediately before adding the first task message", async () => { const task = new Task({ diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 17c53f40d4..4eef2e3c29 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -249,6 +249,34 @@ export const stories: Record = { ) }, + "github-copilot-settings": async () => { + const [{ AppProviders }, { VSCodeLM }, { providerIdentifiers }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/providers/VSCodeLM"), + import("@roo-code/types"), + ]) + function CopilotSettings() { + const [config, setConfig] = useState({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "gpt-5.5", family: "gpt-5.5", version: "1" }, + }) + return ( +
+ + setConfig((previous) => ({ ...previous, [field]: value })) + } + /> +
+ ) + } + return ( + + + + ) + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/components/chat/TooManyToolsWarning.tsx b/webview-ui/src/components/chat/TooManyToolsWarning.tsx index 07302c4c0f..697fad19ae 100644 --- a/webview-ui/src/components/chat/TooManyToolsWarning.tsx +++ b/webview-ui/src/components/chat/TooManyToolsWarning.tsx @@ -4,10 +4,11 @@ import { useTooManyTools } from "@src/hooks/useTooManyTools" import WarningRow from "./WarningRow" /** - * Displays a warning when enabled MCP tools exceed the model's advertised limit. + * Displays a warning when the user has too many MCP tools enabled. + * LLMs get confused when offered too many tools, which can lead to errors. * * The warning is shown when: - * - The model advertises a limit and the enabled tool count exceeds it + * - The total number of enabled tools across all enabled MCP servers exceeds the threshold * * @example * diff --git a/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx b/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx index 456d38ade6..85560201da 100644 --- a/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TooManyToolsWarning.spec.tsx @@ -12,16 +12,10 @@ vi.mock("@/utils/vscode", () => ({ // Mock ExtensionState context with variable mcpServers const mockMcpServers = vi.fn() -const mockModelInfo = vi.fn() - -vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ - useSelectedModel: () => ({ info: mockModelInfo() }), -})) vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ mcpServers: mockMcpServers(), - apiConfiguration: {}, }), })) @@ -58,36 +52,6 @@ describe("TooManyToolsWarning", () => { beforeEach(() => { vi.clearAllMocks() mockMcpServers.mockReturnValue([]) - mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false }) - }) - - const serverWithTools = (count: number) => [ - { - name: "server1", - status: "connected", - disabled: false, - tools: Array.from({ length: count }, (_, i) => ({ name: `tool${i}`, enabledForPrompt: true })), - }, - ] - - it("still warns above the default advisory when the model reports no limit", () => { - mockMcpServers.mockReturnValue(serverWithTools(MAX_MCP_TOOLS_THRESHOLD + 1)) - render() - expect(screen.getByText("Too many tools enabled")).toBeInTheDocument() - }) - - it("uses a provider-supplied limit instead of the default advisory", () => { - mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false, maxTools: 200 }) - mockMcpServers.mockReturnValue(serverWithTools(130)) - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it("warns once a provider-supplied limit is exceeded and reports that limit", () => { - mockModelInfo.mockReturnValue({ contextWindow: 1000, supportsPromptCache: false, maxTools: 20 }) - mockMcpServers.mockReturnValue(serverWithTools(21)) - render() - expect(screen.getByText(/Try to keep it below 20\./)).toBeInTheDocument() }) it("does not render when there are no MCP servers", () => { diff --git a/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx new file mode 100644 index 0000000000..248ff0818a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx @@ -0,0 +1,62 @@ +import type { Page } from "@playwright/test" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" + +const send = (page: Page, data: object) => + page.evaluate((message) => window.dispatchEvent(new MessageEvent("message", { data: message })), data) + +// The host reports context but says nothing about vision, which the UI must show as unreported. +const model = { id: "gpt-5.5", vendor: "copilot", family: "gpt-5.5", version: "1", name: "GPT-5.5" } +const reportedModel = { ...model, modelInfo: { contextWindow: 268_426, supportsPromptCache: false } } + +const mountSettings = async (mount: (name: string) => Promise, page: Page) => { + const component = mountedStory(await mount("github-copilot-settings")) + // The full provider bundle leaves a bare Zod reference after gallery tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + return component +} + +const settle = (component: import("@playwright/test").Locator) => + component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + +test("renders a signed-in account with host-reported capabilities in the VS Code dark theme", async ({ + mount, + page, +}) => { + const component = await mountSettings(mount, page) + await send(page, { + type: "githubCopilotModels", + githubCopilotAccount: "octocat", + vsCodeLmModels: [reportedModel], + }) + + await expect(component.getByRole("status")).toContainText("octocat") + await expect(component.getByText("Image support not reported")).toBeVisible() + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-signed-in-dark.png") +}) + +test("renders the empty state for an account with no Copilot models", async ({ mount, page }) => { + const component = await mountSettings(mount, page) + await send(page, { type: "githubCopilotModels", githubCopilotAccount: "octocat", vsCodeLmModels: [] }) + + await expect(component.getByText(/Signing in does not confirm a Copilot subscription/)).toBeVisible() + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-no-models-dark.png") +}) + +test("renders a model discovery error", async ({ mount, page }) => { + const component = await mountSettings(mount, page) + await send(page, { type: "githubCopilotModels", error: "Model access denied" }) + + await expect(component.getByRole("alert")).toContainText("Model access denied") + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-error-dark.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d460f221bcb98494f30a847c45116c6b13c2085f GIT binary patch literal 29783 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

&vwf?(lDWH`9Mrhj+-6p=}h$hojM71F+C8fCAfH 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


)oOW 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 { setApiConfigurationField={setField} />, ) - const modelInfo = { contextWindow: 260000, supportsImages: true, supportsPromptCache: false, maxTools: 200 } + const modelInfo = { contextWindow: 260000, supportsImages: true, supportsPromptCache: false } act(() => window.dispatchEvent( diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 0ecfdd9cae..5e50c93157 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1434,11 +1434,11 @@ describe("useSelectedModel", () => { ).toBeUndefined() }) - it("uses what the host reports for the selected model: context, vision, and tool limit", () => { + it("uses what the host reports for the selected model: context and vision", () => { reportedModels({ id: "dynamic-vision", family: "dynamic-vision", - modelInfo: { contextWindow: 260000, supportsImages: true, supportsPromptCache: false, maxTools: 200 }, + modelInfo: { contextWindow: 260000, supportsImages: true, supportsPromptCache: false }, }) const selected = selectCopilot({ vendor: "copilot", id: "dynamic-vision", family: "dynamic-vision" }) @@ -1448,7 +1448,6 @@ describe("useSelectedModel", () => { contextWindow: 260000, supportsImages: true, supportsPromptCache: false, - maxTools: 200, }) }) @@ -1486,6 +1485,36 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe(vscodeLlmDefaultModelId) }) + it("shows the window the extension enforces, not a larger curated one, before the host reports", () => { + const [family, entry] = + Object.entries(vscodeLlmModels).find(([, e]) => e.contextWindow !== e.maxInputTokens) ?? [] + expect(family).toBeDefined() + + const selected = selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family }) + + expect(selected.info?.contextWindow).toBe(entry!.maxInputTokens) + expect(selected.info?.contextWindow).toBeLessThan(entry!.contextWindow) + }) + + it("does not let a reported model that says nothing about vision inherit the catalog's flag", () => { + const [family] = Object.entries(vscodeLlmModels).find(([, e]) => e.supportsImages) ?? [] + // An unreported capability arrives with its key absent, as it does after crossing the message boundary. + reportedModels({ + id: "silent", + family: family!, + modelInfo: { contextWindow: 1000, supportsPromptCache: false }, + }) + + expect(selectCopilot({ vendor: "copilot", id: "silent", family }).info?.supportsImages).toBeUndefined() + }) + + it("keeps the catalog's vision flag only for a model the host has not reported", () => { + const [family] = Object.entries(vscodeLlmModels).find(([, e]) => e.supportsImages) ?? [] + reportedModels({ id: "someone-else", family: "someone-else" }) + + expect(selectCopilot({ vendor: "copilot", id: "not-listed", family }).info?.supportsImages).toBe(true) + }) + it("picks the reported model matching the saved id, not merely the first one", () => { reportedModels( { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 3d8aec1ca8..ab569903d9 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -456,7 +456,11 @@ function getSelectedModel({ info: { ...vscodeLlmBaselineModelInfo, ...knownModel, + // The extension enforces the curated input limit, so the UI must not show the larger window. + ...(knownModel && { contextWindow: knownModel.maxInputTokens }), ...reported?.modelInfo, + // A reported model's vision is the host's alone; an omitted key must not fall back to the catalog. + ...(reported && { supportsImages: reported.modelInfo?.supportsImages }), supportsPromptCache: false, }, } diff --git a/webview-ui/src/hooks/useTooManyTools.ts b/webview-ui/src/hooks/useTooManyTools.ts index 2284c3a7d7..ba18b10a70 100644 --- a/webview-ui/src/hooks/useTooManyTools.ts +++ b/webview-ui/src/hooks/useTooManyTools.ts @@ -1,8 +1,7 @@ import { useMemo } from "react" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { countEnabledMcpTools, getMcpToolThreshold } from "@roo-code/types" -import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" +import { MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools } from "@roo-code/types" export interface TooManyToolsInfo { /** Number of enabled and connected MCP servers */ @@ -33,27 +32,25 @@ export interface TooManyToolsInfo { */ export function useTooManyTools(): TooManyToolsInfo { const { t } = useAppTranslation() - const { mcpServers, apiConfiguration } = useExtensionState() - const { info } = useSelectedModel(apiConfiguration) + const { mcpServers } = useExtensionState() const { enabledServerCount, enabledToolCount } = useMemo(() => countEnabledMcpTools(mcpServers), [mcpServers]) - const threshold = getMcpToolThreshold(info) - const isOverThreshold = enabledToolCount > threshold + const isOverThreshold = enabledToolCount > MAX_MCP_TOOLS_THRESHOLD const toolsPart = t("chat:tooManyTools.toolsPart", { count: enabledToolCount }) const serversPart = t("chat:tooManyTools.serversPart", { count: enabledServerCount }) const message = t("chat:tooManyTools.messageTemplate", { tools: toolsPart, servers: serversPart, - threshold, + threshold: MAX_MCP_TOOLS_THRESHOLD, }) return { enabledServerCount, enabledToolCount, isOverThreshold, - threshold, + threshold: MAX_MCP_TOOLS_THRESHOLD, title: t("chat:tooManyTools.title"), message, } From fe26574a596665f8362d4cf61b0939567db4427e Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 18:32:42 -0700 Subject: [PATCH 06/33] fix(webview): drop stale GitHub Copilot sign-in results A plain sign-in and a Reconnect run as separate attempts, so an older one could finish after a newer one and overwrite its account and model list. Tag each request and publish only for the latest. --- .../__tests__/webviewMessageHandler.spec.ts | 45 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 13 +++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index cbde7d33c5..31a8ac7239 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -187,6 +187,51 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + it("drops a stale sign-in that completes after a newer reconnect", async () => { + let finishSignIn!: (value: { account: string; models: [] }) => void + let finishReconnect!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((resolve) => (finishSignIn = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (finishReconnect = resolve))) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const reconnect = webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + + // The newer request finishes first, then the older one arrives late. + finishReconnect({ account: "New User", models: [] }) + await reconnect + finishSignIn({ account: "Old User", models: [] }) + await signIn + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + }) + }) + + it("drops a stale sign-in failure that arrives after a newer success", async () => { + let failSignIn!: (reason: Error) => void + let finishReconnect!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((_resolve, reject) => (failSignIn = reject))) + .mockImplementationOnce(() => new Promise((resolve) => (finishReconnect = resolve))) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const reconnect = webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + + finishReconnect({ account: "New User", models: [] }) + await reconnect + failSignIn(new Error("Sign-in cancelled")) + await signIn + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ error: expect.anything() }), + ) + }) + it("opens VS Code's account management and never signs out itself", async () => { const open = vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c06ac792db..84d717559a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -129,6 +129,9 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +/** Identifies the newest GitHub Copilot sign-in request so a slower, older one cannot overwrite its result. */ +let githubCopilotSignInRequestId = 0 + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1515,26 +1518,34 @@ export const webviewMessageHandler = async ( break case VsCodeLmModelsMessageType.githubCopilotSignIn: - case VsCodeLmModelsMessageType.githubCopilotReconnect: + case VsCodeLmModelsMessageType.githubCopilotReconnect: { + // Plain sign-in and Reconnect run as separate attempts, so an older one can finish after a newer one. + // Only the latest request may publish, or its account and model list would overwrite newer state. + const requestId = ++githubCopilotSignInRequestId + const isLatest = () => requestId === githubCopilotSignInRequestId try { const { models, account } = await connectGitHubCopilot(async (githubCopilotAccount: string) => { + if (!isLatest()) return await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount, }) }, message.type === VsCodeLmModelsMessageType.githubCopilotReconnect) + if (!isLatest()) break await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.githubCopilotSignInResult, vsCodeLmModels: models, githubCopilotAccount: account, }) } catch (error) { + if (!isLatest()) break await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.githubCopilotSignInResult, error: error instanceof Error ? error.message : String(error), }) } break + } case VsCodeLmModelsMessageType.githubCopilotManageAccount: try { await openGitHubAccountManagement() From 77a8abcf3b1c36ca89e5e061365fda2a4032b8b3 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 8 Oct 2026 18:42:43 -0700 Subject: [PATCH 07/33] fix(webview): scope stale Copilot sign-in guard to each view A module-wide request counter let a sign-in in one webview view discard the result a different view was waiting for. Track the latest request per provider so only a view's own newer request makes its older one stale. --- .../__tests__/webviewMessageHandler.spec.ts | 25 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 9 ++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 31a8ac7239..266dc07a65 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -232,6 +232,31 @@ describe("webviewMessageHandler - Copilot authentication", () => { ) }) + it("answers every view that signs in, since only a view's own newer request makes its older one stale", async () => { + const otherView = { + ...mockClineProvider, + postMessageToWebview: vi.fn(), + } as unknown as ClineProvider + let finishFirst!: (value: { account: string; models: [] }) => void + let finishSecond!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (finishSecond = resolve))) + + const first = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const second = webviewMessageHandler(otherView, { type: "githubCopilotSignIn" }) + finishSecond({ account: "Second User", models: [] }) + finishFirst({ account: "First User", models: [] }) + await Promise.all([first, second]) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotSignInResult", githubCopilotAccount: "First User" }), + ) + expect(otherView.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotSignInResult", githubCopilotAccount: "Second User" }), + ) + }) + it("opens VS Code's account management and never signs out itself", async () => { const open = vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 84d717559a..92cb6cf8d0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -129,8 +129,8 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) -/** Identifies the newest GitHub Copilot sign-in request so a slower, older one cannot overwrite its result. */ -let githubCopilotSignInRequestId = 0 +/** The newest GitHub Copilot sign-in request per view, so a slower, older one cannot overwrite its result. */ +const githubCopilotSignInRequestIds = new WeakMap() export const webviewMessageHandler = async ( provider: ClineProvider, @@ -1521,8 +1521,9 @@ export const webviewMessageHandler = async ( case VsCodeLmModelsMessageType.githubCopilotReconnect: { // Plain sign-in and Reconnect run as separate attempts, so an older one can finish after a newer one. // Only the latest request may publish, or its account and model list would overwrite newer state. - const requestId = ++githubCopilotSignInRequestId - const isLatest = () => requestId === githubCopilotSignInRequestId + const requestId = (githubCopilotSignInRequestIds.get(provider) ?? 0) + 1 + githubCopilotSignInRequestIds.set(provider, requestId) + const isLatest = () => githubCopilotSignInRequestIds.get(provider) === requestId try { const { models, account } = await connectGitHubCopilot(async (githubCopilotAccount: string) => { if (!isLatest()) return From e8fdf0830a3b0efea4cac2a2464a0b5be86df824 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Sat, 10 Oct 2026 17:05:24 -0700 Subject: [PATCH 08/33] fix(copilot): address review feedback on refresh races and image support Ignore a passive model refresh that was overtaken by a sign-in so it cannot overwrite the newer account and model list, and clear the refreshing state when a sign-in result supersedes it. Show image support only from what the host reports, matching what the extension enforces. --- .../__tests__/webviewMessageHandler.spec.ts | 114 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 33 ++++- .../settings/providers/VSCodeLM.tsx | 6 +- .../__tests__/ProviderRouting.spec.tsx | 40 ++++++ .../hooks/__tests__/useSelectedModel.spec.ts | 8 +- .../components/ui/hooks/useSelectedModel.ts | 4 +- 6 files changed, 193 insertions(+), 12 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 266dc07a65..e33514d734 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -341,6 +341,120 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + describe("passive refresh racing a sign-in", () => { + const passiveRequest = () => + webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + const signedInResult = { + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + } + + it("discards a refresh that began before a sign-in which finished first", async () => { + let finishModels!: (models: []) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + () => new Promise((resolve) => (finishModels = resolve)), + ) + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "New User", models: [] }) + + const refresh = passiveRequest() + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + finishModels([]) + await refresh + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(signedInResult) + }) + + it("discards a refresh whose model lookup fails after a newer sign-in", async () => { + let failModels!: (reason: Error) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + () => new Promise((_resolve, reject) => (failModels = reject)), + ) + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "New User", models: [] }) + + const refresh = passiveRequest() + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + failModels(new Error("host busy")) + await refresh + + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ error: expect.anything() }), + ) + }) + + it("discards a refresh that began during a sign-in and ended after it", async () => { + let finishSignIn!: (value: { account: string; models: [] }) => void + let finishModels!: (models: []) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + () => new Promise((resolve) => (finishModels = resolve)), + ) + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( + () => new Promise((resolve) => (finishSignIn = resolve)), + ) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const refresh = passiveRequest() + finishSignIn({ account: "New User", models: [] }) + await signIn + finishModels([]) + await refresh + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(signedInResult) + }) + + it("does not make a pending sign-in stale by starting a refresh", async () => { + let finishSignIn!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( + () => new Promise((resolve) => (finishSignIn = resolve)), + ) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + await passiveRequest() + finishSignIn({ account: "New User", models: [] }) + await signIn + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(signedInResult) + }) + + it("still answers a refresh when no sign-in happened meanwhile", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + + await passiveRequest() + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + }) + + it("keeps one view's sign-in from discarding another view's refresh", async () => { + const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "Other", models: [] }) + + const refresh = passiveRequest() + await webviewMessageHandler(otherView, { type: "githubCopilotSignIn" }) + await refresh + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotModels", githubCopilotAccount: "Test User" }), + ) + }) + }) + it("shows model discovery errors instead of silently returning an empty list", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue(new Error("Model access denied")) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 92cb6cf8d0..08270f57c6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -129,8 +129,23 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) -/** The newest GitHub Copilot sign-in request per view, so a slower, older one cannot overwrite its result. */ -const githubCopilotSignInRequestIds = new WeakMap() +type GitHubCopilotViewState = { + /** The newest sign-in request, so a slower, older one cannot overwrite its result. */ + signInId: number + /** Moves whenever a sign-in starts or settles; a passive refresh that began earlier is out of date. */ + changes: number +} + +const githubCopilotViewStates = new WeakMap() + +const getGitHubCopilotViewState = (provider: ClineProvider) => { + let state = githubCopilotViewStates.get(provider) + if (!state) { + state = { signInId: 0, changes: 0 } + githubCopilotViewStates.set(provider, state) + } + return state +} export const webviewMessageHandler = async ( provider: ClineProvider, @@ -1521,9 +1536,10 @@ export const webviewMessageHandler = async ( case VsCodeLmModelsMessageType.githubCopilotReconnect: { // Plain sign-in and Reconnect run as separate attempts, so an older one can finish after a newer one. // Only the latest request may publish, or its account and model list would overwrite newer state. - const requestId = (githubCopilotSignInRequestIds.get(provider) ?? 0) + 1 - githubCopilotSignInRequestIds.set(provider, requestId) - const isLatest = () => githubCopilotSignInRequestIds.get(provider) === requestId + const viewState = getGitHubCopilotViewState(provider) + const requestId = ++viewState.signInId + viewState.changes++ + const isLatest = () => viewState.signInId === requestId try { const { models, account } = await connectGitHubCopilot(async (githubCopilotAccount: string) => { if (!isLatest()) return @@ -1544,6 +1560,8 @@ export const webviewMessageHandler = async ( type: VsCodeLmModelsMessageType.githubCopilotSignInResult, error: error instanceof Error ? error.message : String(error), }) + } finally { + if (isLatest()) viewState.changes++ } break } @@ -1561,15 +1579,20 @@ export const webviewMessageHandler = async ( const type = isCopilot ? VsCodeLmModelsMessageType.githubCopilotModels : VsCodeLmModelsMessageType.vsCodeLmModels + const viewState = isCopilot ? getGitHubCopilotViewState(provider) : undefined + const changesAtStart = viewState?.changes + const isOutOfDate = () => viewState !== undefined && viewState.changes !== changesAtStart try { const githubCopilotAccount = isCopilot ? await getGitHubCopilotAccount() : undefined const vsCodeLmModels = await getVsCodeLmModels(isCopilot ? githubCopilotLanguageModel.selector : {}) + if (isOutOfDate()) break await provider.postMessageToWebview({ type, vsCodeLmModels, ...(isCopilot ? { githubCopilotAccount: githubCopilotAccount ?? null } : {}), }) } catch (error) { + if (isOutOfDate()) break await provider.postMessageToWebview({ type, error: error instanceof Error ? error.message : String(error), diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index f44008890d..898cc19016 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -62,7 +62,11 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL if (message.type === expectedType && (message.vsCodeLmModels !== undefined || message.error)) { setIsRefreshing(false) } - if (isSignInResult) setIsConnecting(false) + // A sign-in result supersedes any refresh still waiting, whose reply may have been discarded as stale. + if (isSignInResult) { + setIsConnecting(false) + setIsRefreshing(false) + } if (message.githubCopilotAccount !== undefined) setAccount(message.githubCopilotAccount ?? undefined) if (isSignInResult || message.error) setError(message.error) if (!isCopilot && !message.error && message.vsCodeLmModels) setLegacyModels(message.vsCodeLmModels) diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index c467b2e9fe..17b51f8577 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -341,6 +341,46 @@ describe("provider model routing", () => { expect(modelPickerMock.mock.lastCall![0].models).toHaveProperty("kept") }) + it("releases a refresh still waiting when a sign-in result arrives, since its own reply may have been discarded", () => { + renderWithQuery( + , + ) + vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "true", + ) + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }, + }), + ), + ) + + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "false", + ) + for (const button of screen.getAllByRole("button")) expect(button).toBeEnabled() + }) + it("keeps sign-in pending during background discovery and displays cancellation", () => { const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) renderWithQuery( diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 5e50c93157..0d8d57be70 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1418,14 +1418,14 @@ describe("useSelectedModel", () => { expect(mockUseGitHubCopilotModels).toHaveBeenLastCalledWith(true) }) - it("describes a model from the curated catalog until the host has reported it", () => { + it("keeps vision unknown until the host reports it, even for a model the catalog knows supports images", () => { const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) ?? [] expect(curatedFamily).toBeDefined() expect( selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family: curatedFamily }).info ?.supportsImages, - ).toBe(true) + ).toBeUndefined() }) it("keeps missing Copilot image capability unknown instead of setting it to false", () => { @@ -1508,11 +1508,11 @@ describe("useSelectedModel", () => { expect(selectCopilot({ vendor: "copilot", id: "silent", family }).info?.supportsImages).toBeUndefined() }) - it("keeps the catalog's vision flag only for a model the host has not reported", () => { + it("keeps vision unknown for a saved model the host's list does not include", () => { const [family] = Object.entries(vscodeLlmModels).find(([, e]) => e.supportsImages) ?? [] reportedModels({ id: "someone-else", family: "someone-else" }) - expect(selectCopilot({ vendor: "copilot", id: "not-listed", family }).info?.supportsImages).toBe(true) + expect(selectCopilot({ vendor: "copilot", id: "not-listed", family }).info?.supportsImages).toBeUndefined() }) it("picks the reported model matching the saved id, not merely the first one", () => { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index ab569903d9..c69ea1cad3 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -459,8 +459,8 @@ function getSelectedModel({ // The extension enforces the curated input limit, so the UI must not show the larger window. ...(knownModel && { contextWindow: knownModel.maxInputTokens }), ...reported?.modelInfo, - // A reported model's vision is the host's alone; an omitted key must not fall back to the catalog. - ...(reported && { supportsImages: reported.modelInfo?.supportsImages }), + // Vision is the host's word alone, as the extension enforces; the catalog's flag is never shown. + supportsImages: reported?.modelInfo?.supportsImages, supportsPromptCache: false, }, } From 3b619c983f2af56ec8c28eabddfa1119e542f680 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Sun, 11 Oct 2026 09:38:55 -0700 Subject: [PATCH 09/33] fix(copilot): address remaining review feedback on account and model refresh Share per-view sign-in state between the message handler and the model broadcast so neither overwrites a newer sign-in result. Refresh the account label with the model list and when GitHub sessions change, so signing out in VS Code clears it. Keep the legacy VS Code LM provider's model keys unchanged and clear a stale error banner once a usable list arrives. --- README.md | 13 +- .../__tests__/github-copilot.spec.ts | 54 +++++ src/api/providers/__tests__/vscode-lm.spec.ts | 32 ++- src/api/providers/github-copilot.ts | 28 ++- src/api/providers/vscode-lm.ts | 30 +-- .../__tests__/webviewMessageHandler.spec.ts | 37 ++-- src/core/webview/webviewMessageHandler.ts | 46 ++-- ...lotModelBroadcast.noAuthentication.spec.ts | 17 ++ .../__tests__/copilotModelBroadcast.spec.ts | 202 +++++++++++++----- .../language-models/copilotModelBroadcast.ts | 61 ++++-- .../language-models/copilotViewState.ts | 18 ++ .../settings/providers/VSCodeLM.tsx | 45 ++-- .../__tests__/ProviderRouting.spec.tsx | 116 ++++++++-- 13 files changed, 498 insertions(+), 201 deletions(-) create mode 100644 src/services/language-models/__tests__/copilotModelBroadcast.noAuthentication.spec.ts create mode 100644 src/services/language-models/copilotViewState.ts diff --git a/README.md b/README.md index 9ee2853848..000fabd0fe 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ signs in successfully and simply sees no models. sign out on a user's behalf, so signing out happens there. It affects Copilot and every other extension using the account. -VS Code manages the GitHub session; Zoo Code never displays or stores the access token. +VS Code manages the GitHub session; Zoo Code never displays or stores the access token. The account +label follows VS Code, so signing out in the Accounts menu clears it. ### Capabilities @@ -70,11 +71,11 @@ changes, and nothing about a model is saved into your settings where it could go - **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one is known. Before the host reports, the curated limit is shown, matching what Zoo Code enforces. -- **Images:** read from the capability the host reports for each live model. A model that reports - nothing shows **Image support not reported** instead of being assumed unsupported or borrowed from - Zoo Code's curated catalog, which only describes a model the host has not listed yet. Images are - sent as image data, not text placeholders, and need a VS Code version that supports image parts; - Zoo Code's minimum supported VS Code version stays 1.100. +- **Images:** read only from the capability the host reports for each model. Until it reports, or if it + reports nothing, the model shows **Image support not reported** instead of being assumed supported + or unsupported, and Zoo Code will not send images to it. Images are sent as image data, not text + placeholders, and need a VS Code version that supports image parts; Zoo Code's minimum supported + VS Code version stays 1.100. - **Tools:** VS Code tells extensions only whether a model supports tools, not how many it accepts, so Zoo Code applies the same advisory it shows for any provider when many MCP tools are enabled. GitHub plan limits, premium-request allowances, organization policies, and backend limits still apply. diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index ba40d3059b..db54d0c793 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -92,6 +92,7 @@ import * as vscode from "vscode" import { GitHubCopilotHandler, connectGitHubCopilot, + getGitHubCopilotModels, getGitHubCopilotAccount, openGitHubAccountManagement, } from "../github-copilot" @@ -562,3 +563,56 @@ describe("GitHubCopilotHandler", () => { }) }) }) + +describe("getGitHubCopilotModels", () => { + const copilotModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + id: "listed-model", + vendor: "copilot", + family: "listed-family", + ...overrides, + }) + + beforeEach(() => vi.clearAllMocks()) + + it("describes each model by identity and by the capabilities the host reports", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + copilotModel({ maxInputTokens: 50000, capabilities: { supportsImageToText: true } }), + ] as never) + + const [listed] = await getGitHubCopilotModels() + + expect(listed).toMatchObject({ + id: "listed-model", + vendor: "copilot", + family: "listed-family", + maxInputTokens: 50000, + modelInfo: { contextWindow: 50000, supportsImages: true }, + }) + }) + + it("asks the host only for Copilot's models", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await getGitHubCopilotModels() + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + }) + + it("omits models that can never work", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + copilotModel({ id: "claude-3.7-sonnet" }), + copilotModel({ id: "kept" }), + ] as never) + + expect((await getGitHubCopilotModels()).map((model) => model.id)).toEqual(["kept"]) + }) + + it("reports a host that has no models as an empty list", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue(undefined as never) + await expect(getGitHubCopilotModels()).resolves.toEqual([]) + }) + + it("surfaces a failed lookup instead of passing it off as 'no models'", async () => { + vi.mocked(vscode.lm.selectChatModels).mockRejectedValue(new Error("host unavailable")) + await expect(getGitHubCopilotModels()).rejects.toThrow("host unavailable") + }) +}) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index c65861fb34..789acf6b91 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -4505,26 +4505,17 @@ describe("getVsCodeLmModels", () => { beforeEach(() => vi.clearAllMocks()) - it("describes each model by identity and by the capabilities the host reports", async () => { - vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ - liveModel({ maxInputTokens: 50000, capabilities: { supportsImageToText: true } }), - ] as never) - - const [listed] = await getVsCodeLmModels({ vendor: "copilot" }) + it("returns the host's models as they are, with no added capability data", async () => { + const model = liveModel({ capabilities: { supportsImageToText: true } }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model] as never) - expect(listed).toMatchObject({ - id: "listed-model", - vendor: "copilot", - family: "listed-family", - maxInputTokens: 50000, - modelInfo: { contextWindow: 50000, supportsImages: true }, - }) + await expect(getVsCodeLmModels()).resolves.toEqual([model]) }) - it("passes the selector through to the host", async () => { + it("asks the host for every model, whatever the vendor", async () => { vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) - await getVsCodeLmModels({ vendor: "copilot" }) - expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + await getVsCodeLmModels() + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({}) }) it("omits models that can never work", async () => { @@ -4541,9 +4532,12 @@ describe("getVsCodeLmModels", () => { await expect(getVsCodeLmModels()).resolves.toEqual([]) }) - it("surfaces a failed lookup instead of passing it off as 'no models'", async () => { + it("keeps its long-standing behaviour of answering a failed lookup with an empty list", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}) vi.mocked(vscode.lm.selectChatModels).mockRejectedValue(new Error("host unavailable")) - await expect(getVsCodeLmModels({ vendor: "copilot" })).rejects.toThrow("host unavailable") - await expect(getVsCodeLmModels()).rejects.toThrow("host unavailable") + + await expect(getVsCodeLmModels()).resolves.toEqual([]) + expect(logged).toHaveBeenCalledWith(expect.stringContaining("host unavailable")) + logged.mockRestore() }) }) diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts index 7ab4860561..ecd69d2727 100644 --- a/src/api/providers/github-copilot.ts +++ b/src/api/providers/github-copilot.ts @@ -8,7 +8,7 @@ import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-manag import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format" -import { VsCodeLmHandler, containsImageBlock, getVsCodeLmModels } from "./vscode-lm" +import { VSCODE_LM_STATIC_BLACKLIST, VsCodeLmHandler, containsImageBlock } from "./vscode-lm" import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" /** @@ -93,7 +93,27 @@ function getGitHubSession(options: vscode.AuthenticationGetSessionOptions) { ) } -type SignInResult = { account: string; models: Awaited> } +/** + * Lists the Copilot models the host currently offers, with the capabilities it reports for each. A + * failure reaches the caller rather than becoming an empty list: "no models" and "could not ask" call + * for different handling. + */ +export async function getGitHubCopilotModels() { + const models = (await vscode.lm.selectChatModels(githubCopilotLanguageModel.selector)) || [] + return models + .filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + .map((model) => ({ + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + name: model.name, + maxInputTokens: model.maxInputTokens, + modelInfo: getVsCodeLmModelInfo(model), + })) +} + +type SignInResult = { account: string; models: Awaited> } // Keyed by whether a fresh session was requested, so a Reconnect is never answered by a plain sign-in. const signInsInFlight = new Map>() @@ -111,7 +131,7 @@ export function connectGitHubCopilot(onAuthenticated?: (account: string) => Prom const attempt = (async (): Promise => { try { - const { authProviderId, authScopeSets, chatExtensionId, selector } = githubCopilotLanguageModel + const { authProviderId, authScopeSets, chatExtensionId } = githubCopilotLanguageModel const session = await vscode.authentication.getSession( authProviderId, authScopeSets[0], @@ -120,7 +140,7 @@ export function connectGitHubCopilot(onAuthenticated?: (account: string) => Prom if (!session) throw new Error("GitHub sign-in did not complete. Please try again.") await onAuthenticated?.(session.account.label) await vscode.extensions.getExtension(chatExtensionId)?.activate() - return { account: session.account.label, models: await getVsCodeLmModels(selector) } + return { account: session.account.label, models: await getGitHubCopilotModels() } } finally { signInsInFlight.delete(forceNewSession) } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index ebc99ab91b..32dbc54791 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -23,7 +23,6 @@ import { sanitizeSurrogatesDeep, sanitizeToolNameSurrogates, } from "../transform/vscode-lm-format" -import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-management/context-error-handling" @@ -1522,23 +1521,16 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } // Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work -const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] +export const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] -/** - * Lists the language models the host currently offers. A failure is surfaced to the caller rather - * than reported as an empty list: "no models" and "could not ask" call for different handling. - */ -export async function getVsCodeLmModels(selector: vscode.LanguageModelChatSelector = {}) { - const models = (await vscode.lm.selectChatModels(selector)) || [] - return models - .filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) - .map((model) => ({ - id: model.id, - vendor: model.vendor, - family: model.family, - version: model.version, - name: model.name, - maxInputTokens: model.maxInputTokens, - modelInfo: getVsCodeLmModelInfo(model), - })) +export async function getVsCodeLmModels() { + try { + const models = (await vscode.lm.selectChatModels({})) || [] + return models.filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + } catch (error) { + console.error( + `Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + return [] + } } diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index e33514d734..d511f4379a 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -286,40 +286,31 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) describe("legacy VS Code LM model requests", () => { - it("lists every model unscoped and never looks up a GitHub account", async () => { + it("lists the host's models as before and never looks up a GitHub account", async () => { const account = vi.spyOn(copilotProvider, "getGitHubCopilotAccount") + const copilotModels = vi.spyOn(copilotProvider, "getGitHubCopilotModels") const models = vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) - expect(models).toHaveBeenCalledWith({}) + expect(models).toHaveBeenCalledWith() expect(account).not.toHaveBeenCalled() + expect(copilotModels).not.toHaveBeenCalled() expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ type: "vsCodeLmModels", vsCodeLmModels: [], }) }) - - it("reports a failed lookup to the legacy list without touching the Copilot one", async () => { - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue("host busy") - - await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ - type: "vsCodeLmModels", - error: "host busy", - }) - }) }) it("restores a signed-in account during passive model refresh", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, }) - expect(vsCodeLmProvider.getVsCodeLmModels).toHaveBeenCalledWith({ vendor: "copilot" }) + expect(copilotProvider.getGitHubCopilotModels).toHaveBeenCalled() expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "githubCopilotModels", githubCopilotAccount: "Test User", @@ -329,7 +320,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("reports no account as null, so the webview clears a stale sign-in rather than keeping it", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, @@ -356,7 +347,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("discards a refresh that began before a sign-in which finished first", async () => { let finishModels!: (models: []) => void vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( () => new Promise((resolve) => (finishModels = resolve)), ) vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "New User", models: [] }) @@ -373,7 +364,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("discards a refresh whose model lookup fails after a newer sign-in", async () => { let failModels!: (reason: Error) => void vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( () => new Promise((_resolve, reject) => (failModels = reject)), ) vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "New User", models: [] }) @@ -392,7 +383,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { let finishSignIn!: (value: { account: string; models: [] }) => void let finishModels!: (models: []) => void vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockImplementation( + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( () => new Promise((resolve) => (finishModels = resolve)), ) vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( @@ -413,7 +404,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("does not make a pending sign-in stale by starting a refresh", async () => { let finishSignIn!: (value: { account: string; models: [] }) => void vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( () => new Promise((resolve) => (finishSignIn = resolve)), ) @@ -428,7 +419,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("still answers a refresh when no sign-in happened meanwhile", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) await passiveRequest() @@ -442,7 +433,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("keeps one view's sign-in from discarding another view's refresh", async () => { const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) vi.spyOn(copilotProvider, "connectGitHubCopilot").mockResolvedValue({ account: "Other", models: [] }) const refresh = passiveRequest() @@ -457,7 +448,7 @@ describe("webviewMessageHandler - Copilot authentication", () => { it("shows model discovery errors instead of silently returning an empty list", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") - vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue(new Error("Model access denied")) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockRejectedValue(new Error("Model access denied")) await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 08270f57c6..94f952e35e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -81,9 +81,11 @@ import { searchCommits } from "../../utils/git" import { exportSettings, importSettingsWithFeedback } from "../config/importExport" import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" +import { getGitHubCopilotViewState } from "../../services/language-models/copilotViewState" import { connectGitHubCopilot, getGitHubCopilotAccount, + getGitHubCopilotModels, openGitHubAccountManagement, } from "../../api/providers/github-copilot" import { openMention } from "../mentions" @@ -129,24 +131,6 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) -type GitHubCopilotViewState = { - /** The newest sign-in request, so a slower, older one cannot overwrite its result. */ - signInId: number - /** Moves whenever a sign-in starts or settles; a passive refresh that began earlier is out of date. */ - changes: number -} - -const githubCopilotViewStates = new WeakMap() - -const getGitHubCopilotViewState = (provider: ClineProvider) => { - let state = githubCopilotViewStates.get(provider) - if (!state) { - state = { signInId: 0, changes: 0 } - githubCopilotViewStates.set(provider, state) - } - return state -} - export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1575,26 +1559,28 @@ export const webviewMessageHandler = async ( } break case VsCodeLmModelsMessageType.requestVsCodeLmModels: { - const isCopilot = message.apiConfiguration?.apiProvider === providerIdentifiers.githubCopilot - const type = isCopilot - ? VsCodeLmModelsMessageType.githubCopilotModels - : VsCodeLmModelsMessageType.vsCodeLmModels - const viewState = isCopilot ? getGitHubCopilotViewState(provider) : undefined - const changesAtStart = viewState?.changes - const isOutOfDate = () => viewState !== undefined && viewState.changes !== changesAtStart + if (message.apiConfiguration?.apiProvider !== providerIdentifiers.githubCopilot) { + const vsCodeLmModels = await getVsCodeLmModels() + // TODO: Cache like we do for OpenRouter, etc? + await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) + break + } + const viewState = getGitHubCopilotViewState(provider) + const changesAtStart = viewState.changes + const isOutOfDate = () => viewState.changes !== changesAtStart try { - const githubCopilotAccount = isCopilot ? await getGitHubCopilotAccount() : undefined - const vsCodeLmModels = await getVsCodeLmModels(isCopilot ? githubCopilotLanguageModel.selector : {}) + const githubCopilotAccount = await getGitHubCopilotAccount() + const vsCodeLmModels = await getGitHubCopilotModels() if (isOutOfDate()) break await provider.postMessageToWebview({ - type, + type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels, - ...(isCopilot ? { githubCopilotAccount: githubCopilotAccount ?? null } : {}), + githubCopilotAccount: githubCopilotAccount ?? null, }) } catch (error) { if (isOutOfDate()) break await provider.postMessageToWebview({ - type, + type: VsCodeLmModelsMessageType.githubCopilotModels, error: error instanceof Error ? error.message : String(error), }) } diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.noAuthentication.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.noAuthentication.spec.ts new file mode 100644 index 0000000000..1417041f63 --- /dev/null +++ b/src/services/language-models/__tests__/copilotModelBroadcast.noAuthentication.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest" + +import { registerCopilotModelBroadcast } from "../copilotModelBroadcast" + +// A host (or test double) that offers language models but no authentication API at all. +vi.mock("vscode", () => ({ lm: { onDidChangeChatModels: vi.fn(() => ({ dispose: vi.fn() })) } })) +vi.mock("../../../api/providers/github-copilot", () => ({ + getGitHubCopilotModels: vi.fn(), + getGitHubCopilotAccount: vi.fn(), +})) + +describe("registerCopilotModelBroadcast without an authentication API", () => { + it("registers and disposes cleanly, still following model changes", () => { + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) + expect(() => registration.dispose()).not.toThrow() + }) +}) diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts index 78121e50ea..5a9983a4d1 100644 --- a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -1,23 +1,38 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" import * as vscode from "vscode" -import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" +import { VsCodeLmModelsMessageType } from "@roo-code/types" import { registerCopilotModelBroadcast, type CopilotModelBroadcastTarget } from "../copilotModelBroadcast" +import { getGitHubCopilotViewState } from "../copilotViewState" -vi.mock("vscode", () => ({ lm: { onDidChangeChatModels: vi.fn() } })) -vi.mock("../../../api/providers/vscode-lm", () => ({ getVsCodeLmModels: vi.fn() })) +vi.mock("vscode", () => ({ + lm: { onDidChangeChatModels: vi.fn() }, + authentication: { onDidChangeSessions: vi.fn() }, +})) +vi.mock("../../../api/providers/github-copilot", () => ({ + getGitHubCopilotModels: vi.fn(), + getGitHubCopilotAccount: vi.fn(), +})) -type Models = Awaited> +type Models = Awaited> const model = (id: string) => ({ id, vendor: "copilot", family: id, version: "1", name: id, maxInputTokens: 1 }) - -function setup(overrides: { targets?: CopilotModelBroadcastTarget[]; fetchModels?: ReturnType } = {}) { +const newTarget = () => ({ postMessageToWebview: vi.fn().mockResolvedValue(undefined) }) + +function setup( + overrides: { + targets?: CopilotModelBroadcastTarget[] + fetchModels?: ReturnType + fetchAccount?: ReturnType + } = {}, +) { let notify: () => void = () => {} const subscription = { dispose: vi.fn() } - const target = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const target = newTarget() const targets = overrides.targets ?? [target] const fetchModels = overrides.fetchModels ?? vi.fn().mockResolvedValue([model("a")] as Models) + const fetchAccount = overrides.fetchAccount ?? vi.fn().mockResolvedValue("octocat") const log = vi.fn() const registration = registerCopilotModelBroadcast({ getTargets: () => targets, @@ -28,32 +43,45 @@ function setup(overrides: { targets?: CopilotModelBroadcastTarget[]; fetchModels return subscription as never }, fetchModels: fetchModels as never, + fetchAccount: fetchAccount as never, }) - return { notify: () => notify(), registration, subscription, target, targets, fetchModels, log } + return { notify: () => notify(), registration, subscription, target, targets, fetchModels, fetchAccount, log } } describe("registerCopilotModelBroadcast", () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) - it("looks models up once for the Copilot vendor and posts them to every view", async () => { - const second = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } - const first = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } - const { notify, fetchModels } = setup({ targets: [first, second] }) + it("looks models and the account up once and posts both to every view", async () => { + const first = newTarget() + const second = newTarget() + const { notify, fetchModels, fetchAccount } = setup({ targets: [first, second] }) notify() await vi.advanceTimersByTimeAsync(100) expect(fetchModels).toHaveBeenCalledTimes(1) - expect(fetchModels).toHaveBeenCalledWith(githubCopilotLanguageModel.selector) + expect(fetchAccount).toHaveBeenCalledTimes(1) for (const view of [first, second]) { expect(view.postMessageToWebview).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a")], + githubCopilotAccount: "octocat", }) } }) + it("reports no account as null, so a sign-out done in VS Code clears the label rather than leaving it", async () => { + const { notify, target } = setup({ fetchAccount: vi.fn().mockResolvedValue(undefined) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ githubCopilotAccount: null }), + ) + }) + it("collapses a burst of change events into one refresh", async () => { const { notify, fetchModels } = setup() @@ -78,7 +106,7 @@ describe("registerCopilotModelBroadcast", () => { it("includes views that open after registration", async () => { const targets: CopilotModelBroadcastTarget[] = [] const { notify } = setup({ targets }) - const late = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const late = newTarget() targets.push(late) notify() @@ -87,15 +115,33 @@ describe("registerCopilotModelBroadcast", () => { expect(late.postMessageToWebview).toHaveBeenCalledTimes(1) }) - it("logs a failed lookup and posts nothing, so views keep their last known list", async () => { + it("logs a failed lookup and posts nothing, so views keep their last known state", async () => { const { notify, target, log } = setup({ fetchModels: vi.fn().mockRejectedValue(new Error("host busy")) }) notify() await vi.advanceTimersByTimeAsync(100) expect(target.postMessageToWebview).not.toHaveBeenCalled() - expect(log).toHaveBeenCalledTimes(1) - expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: host busy") + expect(log).toHaveBeenCalledExactlyOnceWith("Failed to refresh GitHub Copilot state: host busy") + }) + + it("does not report a failed account lookup as a sign-out", async () => { + const { notify, target, log } = setup({ fetchAccount: vi.fn().mockRejectedValue(new Error("auth down")) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot state: auth down") + }) + + it("logs a failure that is not an Error as text", async () => { + const { notify, log } = setup({ fetchModels: vi.fn().mockRejectedValue("plain failure") }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot state: plain failure") }) it("posts an empty list when the host genuinely reports no models", async () => { @@ -107,6 +153,7 @@ describe("registerCopilotModelBroadcast", () => { expect(target.postMessageToWebview).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [], + githubCopilotAccount: "octocat", }) }) @@ -131,6 +178,38 @@ describe("registerCopilotModelBroadcast", () => { ) }) + describe("a view that signs in while a lookup runs", () => { + it("is skipped, because its own sign-in result is newer than the lookup", async () => { + let finishModels: (models: Models) => void = () => {} + const fetchModels = vi + .fn() + .mockImplementation(() => new Promise((resolve) => (finishModels = resolve))) + const signingIn = newTarget() + const bystander = newTarget() + const { notify } = setup({ targets: [signingIn, bystander], fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + getGitHubCopilotViewState(signingIn).changes++ + finishModels([model("a")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(signingIn.postMessageToWebview).not.toHaveBeenCalled() + expect(bystander.postMessageToWebview).toHaveBeenCalledTimes(1) + }) + + it("is served normally when the sign-in finished before the lookup began", async () => { + const view = newTarget() + getGitHubCopilotViewState(view).changes += 3 + const { notify } = setup({ targets: [view] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(view.postMessageToWebview).toHaveBeenCalledTimes(1) + }) + }) + it("stops listening and cancels a pending refresh when disposed", async () => { const { notify, registration, subscription, fetchModels } = setup() @@ -165,53 +244,76 @@ describe("registerCopilotModelBroadcast", () => { expect(() => registration.dispose()).not.toThrow() }) - it("logs a failure that is not an Error as text", async () => { - const { notify, log } = setup({ fetchModels: vi.fn().mockRejectedValue("plain failure") }) - - notify() - await vi.advanceTimersByTimeAsync(100) - - expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: plain failure") - }) - describe("default host wiring", () => { - const hostEvent = vi.mocked(vscode.lm.onDidChangeChatModels) - - it("listens to the host's model-change event and releases it on dispose", () => { - const hostSubscription = { dispose: vi.fn() } - hostEvent.mockReturnValue(hostSubscription as never) - - const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) - expect(hostEvent).toHaveBeenCalledWith(expect.any(Function)) - - registration.dispose() - expect(hostSubscription.dispose).toHaveBeenCalledTimes(1) - }) + const modelEvent = vi.mocked(vscode.lm.onDidChangeChatModels) + const sessionEvent = vi.mocked(vscode.authentication.onDidChangeSessions) + const raise = { + models: () => {}, + sessions: (_providerId: string) => {}, + } - it("refreshes when the host raises the event", async () => { - let raise: () => void = () => {} - hostEvent.mockImplementation(((listener: () => void) => { - raise = listener + beforeEach(() => { + modelEvent.mockReset() + sessionEvent.mockReset() + modelEvent.mockImplementation(((listener: () => void) => { + raise.models = listener return { dispose: vi.fn() } }) as never) - const fetchModels = vi.fn().mockResolvedValue([]) + sessionEvent.mockImplementation(((listener: (event: { provider: { id: string } }) => void) => { + raise.sessions = (id) => listener({ provider: { id } }) + return { dispose: vi.fn() } + }) as never) + }) - registerCopilotModelBroadcast({ + const register = () => { + const fetchModels = vi.fn().mockResolvedValue([]) + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn(), - fetchModels: fetchModels as never, debounceMs: 10, + fetchModels: fetchModels as never, + fetchAccount: vi.fn().mockResolvedValue("octocat") as never, }) - raise() + return { fetchModels, registration } + } + + it("refreshes when the host changes its models", async () => { + const { fetchModels } = register() + raise.models() await vi.advanceTimersByTimeAsync(10) + expect(fetchModels).toHaveBeenCalledTimes(1) + }) + it("refreshes when GitHub's sessions change, such as after signing out in VS Code", async () => { + const { fetchModels } = register() + raise.sessions("github") + await vi.advanceTimersByTimeAsync(10) expect(fetchModels).toHaveBeenCalledTimes(1) }) - it("tolerates a host that does not expose the event", () => { - hostEvent.mockReturnValue(undefined as never) - const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) - expect(() => registration.dispose()).not.toThrow() + it("ignores session changes of other authentication providers", async () => { + const { fetchModels } = register() + raise.sessions("microsoft") + await vi.advanceTimersByTimeAsync(50) + expect(fetchModels).not.toHaveBeenCalled() + }) + + it("releases both host subscriptions on dispose", () => { + const modelSubscription = { dispose: vi.fn() } + const sessionSubscription = { dispose: vi.fn() } + modelEvent.mockReturnValue(modelSubscription as never) + sessionEvent.mockReturnValue(sessionSubscription as never) + + register().registration.dispose() + + expect(modelSubscription.dispose).toHaveBeenCalledTimes(1) + expect(sessionSubscription.dispose).toHaveBeenCalledTimes(1) + }) + + it("tolerates a host that exposes neither event", () => { + modelEvent.mockReturnValue(undefined as never) + sessionEvent.mockReturnValue(undefined as never) + expect(() => register().registration.dispose()).not.toThrow() }) }) }) diff --git a/src/services/language-models/copilotModelBroadcast.ts b/src/services/language-models/copilotModelBroadcast.ts index 90fbcf5388..26aa0d3a7c 100644 --- a/src/services/language-models/copilotModelBroadcast.ts +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -2,12 +2,14 @@ import * as vscode from "vscode" import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" -import { getVsCodeLmModels } from "../../api/providers/vscode-lm" +import { getGitHubCopilotAccount, getGitHubCopilotModels } from "../../api/providers/github-copilot" +import { getGitHubCopilotViewState } from "./copilotViewState" export interface CopilotModelBroadcastTarget { postMessageToWebview(message: { type: typeof VsCodeLmModelsMessageType.githubCopilotModels - vsCodeLmModels: Awaited> + vsCodeLmModels: Awaited> + githubCopilotAccount: string | null }): Promise } @@ -17,27 +19,43 @@ export interface CopilotModelBroadcastOptions { log: (message: string) => void /** Collapses the burst of events VS Code raises while models register into one refresh. */ debounceMs?: number + /** Calls the listener when the host's models or its GitHub sessions change. */ subscribe?: (listener: () => void) => vscode.Disposable | undefined - fetchModels?: typeof getVsCodeLmModels + fetchModels?: typeof getGitHubCopilotModels + fetchAccount?: typeof getGitHubCopilotAccount } const DEFAULT_DEBOUNCE_MS = 250 +const subscribeToHost = (listener: () => void): vscode.Disposable => { + const subscriptions = [ + "lm" in vscode ? vscode.lm.onDidChangeChatModels?.(listener) : undefined, + "authentication" in vscode + ? vscode.authentication.onDidChangeSessions?.((event) => { + if (event.provider.id === githubCopilotLanguageModel.authProviderId) listener() + }) + : undefined, + ] + return { dispose: () => subscriptions.forEach((subscription) => subscription?.dispose()) } +} + /** - * Keeps every open webview's Copilot model list current as the host registers or removes models - * (sign-in, sign-out, entitlement changes). + * Keeps every open webview's Copilot account and model list current as the host registers or removes + * models and as GitHub sessions change, including a sign-out done in VS Code's own account menu. * * One subscription serves all views, so a change costs one lookup however many views exist. A failed - * lookup is logged and leaves each view's last known list in place: reporting failure as "no models" - * would wrongly present a transient error as a lost entitlement. + * lookup is logged and leaves each view's last known state in place: reporting failure as "signed out" + * or "no models" would wrongly present a transient error as a lost account or entitlement. A view that + * signed in while the lookup ran is skipped, since its own result is newer. */ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOptions): vscode.Disposable { const { getTargets, log, debounceMs = DEFAULT_DEBOUNCE_MS, - subscribe = (listener) => ("lm" in vscode ? vscode.lm.onDidChangeChatModels?.(listener) : undefined), - fetchModels = getVsCodeLmModels, + subscribe = subscribeToHost, + fetchModels = getGitHubCopilotModels, + fetchAccount = getGitHubCopilotAccount, } = options let timer: ReturnType | undefined @@ -46,20 +64,29 @@ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOpti const refresh = async () => { const request = ++latestRequest + const changesAtStart = new Map( + getTargets().map((target) => [target, getGitHubCopilotViewState(target).changes]), + ) try { - const vsCodeLmModels = await fetchModels(githubCopilotLanguageModel.selector) + const [vsCodeLmModels, account] = await Promise.all([fetchModels(), fetchAccount()]) // A newer change superseded this lookup, or the extension is shutting down. if (disposed || request !== latestRequest) return await Promise.all( - getTargets().map((target) => - target.postMessageToWebview({ - type: VsCodeLmModelsMessageType.githubCopilotModels, - vsCodeLmModels, - }), - ), + getTargets() + .filter((target) => { + const started = changesAtStart.get(target) + return started === undefined || getGitHubCopilotViewState(target).changes === started + }) + .map((target) => + target.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels, + githubCopilotAccount: account ?? null, + }), + ), ) } catch (error) { - log(`Failed to refresh GitHub Copilot models: ${error instanceof Error ? error.message : String(error)}`) + log(`Failed to refresh GitHub Copilot state: ${error instanceof Error ? error.message : String(error)}`) } } diff --git a/src/services/language-models/copilotViewState.ts b/src/services/language-models/copilotViewState.ts new file mode 100644 index 0000000000..70b04a456c --- /dev/null +++ b/src/services/language-models/copilotViewState.ts @@ -0,0 +1,18 @@ +type GitHubCopilotViewState = { + /** The newest sign-in request, so a slower, older one cannot overwrite its result. */ + signInId: number + /** Moves whenever a sign-in starts or settles; anything that began earlier is out of date. */ + changes: number +} + +const viewStates = new WeakMap() + +/** Per-view bookkeeping that lets sign-in, passive refresh and broadcast discard each other's stale replies. */ +export function getGitHubCopilotViewState(view: object): GitHubCopilotViewState { + let state = viewStates.get(view) + if (!state) { + state = { signInId: 0, changes: 0 } + viewStates.set(view, state) + } + return state +} diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index 898cc19016..ff8fb918a3 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -69,6 +69,8 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL } if (message.githubCopilotAccount !== undefined) setAccount(message.githubCopilotAccount ?? undefined) if (isSignInResult || message.error) setError(message.error) + // A usable list proves the earlier failure has passed, so its banner must not outlive it. + else if (message.vsCodeLmModels) setError(undefined) if (!isCopilot && !message.error && message.vsCodeLmModels) setLegacyModels(message.vsCodeLmModels) }, [isCopilot], @@ -76,12 +78,20 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL useEvent("message", onMessage) - // Convert VSCode LM models array to Record format for ModelPicker + // Copilot keys models by id and saves the exact model; the legacy provider keeps its original vendor/family shape. const modelsRecord = useMemo((): Record => { return vsCodeLmModels.reduce( (acc, model) => { - const modelId = model.id ?? `${model.vendor}/${model.family}` - acc[modelId] = { + if (!isCopilot) { + acc[`${model.vendor}/${model.family}`] = { + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + description: `${model.vendor} - ${model.family}`, + } + return acc + } + acc[model.id ?? `${model.vendor}/${model.family}`] = { maxTokens: 0, contextWindow: model.maxInputTokens ?? 0, supportsPromptCache: false, @@ -93,39 +103,34 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL }, {} as Record, ) - }, [vsCodeLmModels]) + }, [vsCodeLmModels, isCopilot]) - // Transform string model ID to { vendor, family } object for storage const valueTransform = useCallback( (modelId: string) => { - const model = vsCodeLmModels.find((candidate) => candidate.id === modelId) + const model = isCopilot ? vsCodeLmModels.find((candidate) => candidate.id === modelId) : undefined if (model) { - return { - id: model.id, - vendor: model.vendor, - family: model.family, - version: model.version, - } + return { id: model.id, vendor: model.vendor, family: model.family, version: model.version } } const [vendor, family] = modelId.split("/") return { vendor, family } }, - [vsCodeLmModels], + [vsCodeLmModels, isCopilot], ) - // Transform stored { vendor, family } object back to display string const displayTransform = useCallback( (value: unknown) => { if (!value) return "" const selector = value as LanguageModelChatSelector - if (selector.id && vsCodeLmModels.some((model) => model.id === selector.id)) return selector.id - const model = vsCodeLmModels.find( - (candidate) => candidate.vendor === selector.vendor && candidate.family === selector.family, - ) - if (model?.id) return model.id + if (isCopilot) { + if (selector.id && vsCodeLmModels.some((model) => model.id === selector.id)) return selector.id + const model = vsCodeLmModels.find( + (candidate) => candidate.vendor === selector.vendor && candidate.family === selector.family, + ) + if (model?.id) return model.id + } return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" }, - [vsCodeLmModels], + [vsCodeLmModels, isCopilot], ) return ( diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index 17b51f8577..e737cfd2e0 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -482,18 +482,54 @@ describe("provider model routing", () => { , ) + // Raw host models carry an id and a version, which the legacy provider has never used. const listed = [ - { id: "m1", vendor: "copilot", family: "fam", version: "1" }, + { id: "m1", vendor: "copilot", family: "fam", version: "1", name: "Model One" }, { vendor: "other", family: "no-id" }, ] - it("lists the legacy provider's models from its own messages and names its service accordingly", () => { - mount(providerIdentifiers.vscodeLm) + describe("legacy VS Code LM provider, unchanged from before Copilot support", () => { + beforeEach(async () => { + mount(providerIdentifiers.vscodeLm) + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + await waitFor(() => expect(modelPickerMock).toHaveBeenCalled()) + }) + + it("names its service accordingly and keys every model by vendor and family, never by id", () => { + expect(pickerProps().serviceName).toBe("VS Code LM") + expect(Object.keys(pickerProps().models ?? {})).toEqual(["copilot/fam", "other/no-id"]) + }) + + it("describes models plainly, without Copilot's capability or display data", () => { + expect(pickerProps().models?.["copilot/fam"]).toEqual({ + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + description: "copilot - fam", + }) + }) - send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + it("saves a chosen model as vendor and family only, so a profile is never pinned to one id or version", () => { + expect(pickerProps().valueTransform?.("copilot/fam")).toEqual({ vendor: "copilot", family: "fam" }) + }) - expect(pickerProps().serviceName).toBe("VS Code LM") - expect(Object.keys(pickerProps().models ?? {})).toEqual(["m1", "other/no-id"]) + it("does not treat a model's id as a selection, even when one is listed", () => { + expect(pickerProps().valueTransform?.("m1")).toEqual({ vendor: "m1", family: undefined }) + }) + + it.each([ + ["nothing stored", undefined, ""], + ["a stored vendor and family", { vendor: "copilot", family: "fam" }, "copilot/fam"], + [ + "a stored vendor and family that is no longer listed", + { vendor: "gone", family: "model" }, + "gone/model", + ], + ["a selector too incomplete to name a model", { vendor: "copilot" }, ""], + ["a selector holding only an id", { id: "m1" }, ""], + ])("shows %s as the right label", (_label, stored, expected) => { + expect(pickerProps().displayTransform?.(stored)).toBe(expected) + }) }) it("keeps each provider's list separate: the legacy picker ignores Copilot messages", () => { @@ -512,13 +548,20 @@ describe("provider model routing", () => { expect(modelPickerMock).not.toHaveBeenCalled() }) - describe("stored value mapping", () => { - beforeEach(() => { - mount(providerIdentifiers.vscodeLm) - send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + describe("GitHub Copilot", () => { + beforeEach(async () => { + mount(providerIdentifiers.githubCopilot) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: listed }) + await waitFor(() => expect(modelPickerMock).toHaveBeenCalled()) }) - it("stores a listed model by its identity, with no capability snapshot", () => { + it("names its service accordingly and keys a model by its id, falling back to vendor and family", () => { + expect(pickerProps().serviceName).toBe("GitHub Copilot") + expect(Object.keys(pickerProps().models ?? {})).toEqual(["m1", "other/no-id"]) + expect(pickerProps().models?.m1).toMatchObject({ displayName: "Model One" }) + }) + + it("saves a chosen model by its exact identity, with no capability snapshot", () => { expect(pickerProps().valueTransform?.("m1")).toEqual({ id: "m1", vendor: "copilot", @@ -527,7 +570,7 @@ describe("provider model routing", () => { }) }) - it("stores an unlisted value as vendor and family", () => { + it("saves an unlisted value as vendor and family", () => { expect(pickerProps().valueTransform?.("acme/model-x")).toEqual({ vendor: "acme", family: "model-x" }) }) @@ -545,13 +588,60 @@ describe("provider model routing", () => { { vendor: "gone", family: "model" }, "gone/model", ], - ["a stored selector too incomplete to name a model", { vendor: "copilot" }, ""], + ["a selector too incomplete to name a model", { vendor: "copilot" }, ""], ])("shows %s as the right label", (_label, stored, expected) => { expect(pickerProps().displayTransform?.(stored)).toBe(expected) }) }) }) + describe("error banner", () => { + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + + it.each([ + ["Copilot", providerIdentifiers.githubCopilot, VsCodeLmModelsMessageType.githubCopilotModels], + ["the legacy provider", providerIdentifiers.vscodeLm, VsCodeLmModelsMessageType.vsCodeLmModels], + ])("is cleared for %s once a usable model list arrives", (_label, provider, type) => { + renderWithQuery( + , + ) + send({ type, error: "Model discovery failed" }) + expect(screen.getByRole("alert")).toHaveTextContent("Model discovery failed") + + send({ type, vsCodeLmModels: [{ id: "m1", vendor: "copilot", family: "fam", version: "1" }] }) + + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("is cleared by an empty list too, since an empty answer is still an answer", () => { + renderWithQuery( + , + ) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, error: "Model discovery failed" }) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [] }) + + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("survives an account-only update, which says nothing about whether discovery recovered", () => { + renderWithQuery( + , + ) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, error: "Model discovery failed" }) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "octocat" }) + + expect(screen.getByRole("alert")).toHaveTextContent("Model discovery failed") + }) + }) + it("keeps the legacy VS Code LM provider free of Copilot login controls", () => { renderWithQuery( Date: Sun, 11 Oct 2026 12:24:46 -0700 Subject: [PATCH 10/33] fix(webview): style the VS Code LM model note as advisory text The note about provider-wrapped models used the error foreground, so it read as a failure next to a working setup. Render it as description text for both the GitHub Copilot and VS Code LM API providers. --- .../src/components/settings/providers/VSCodeLM.tsx | 3 ++- .../providers/__tests__/ProviderRouting.spec.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index ff8fb918a3..b14c38c9a7 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -238,7 +238,8 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL )} -
{t("settings:providers.vscodeLmWarning")}
+ {/* Advisory, not an error: error styling here would read as a failure next to a working setup. */} +
{t("settings:providers.vscodeLmWarning")}
) } diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index e737cfd2e0..d165ead136 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -642,6 +642,18 @@ describe("provider model routing", () => { }) }) + it.each([ + ["Copilot", providerIdentifiers.githubCopilot], + ["the legacy provider", providerIdentifiers.vscodeLm], + ])("styles the model-wrapping note for %s as advisory text, not as an error", (_label, provider) => { + renderWithQuery() + + const note = screen.getByText("settings:providers.vscodeLmWarning") + expect(note).toHaveClass("text-vscode-descriptionForeground") + expect(note).not.toHaveClass("text-vscode-errorForeground") + expect(note).not.toHaveAttribute("role", "alert") + }) + it("keeps the legacy VS Code LM provider free of Copilot login controls", () => { renderWithQuery( Date: Sun, 11 Oct 2026 12:29:54 -0700 Subject: [PATCH 11/33] test(webview): update GitHub Copilot settings baselines for the advisory note Regenerated by the pinned Playwright container in CI (webview-visual-regression artifact), per webview-ui/AGENTS.md. --- .../github-copilot-error-dark.png | Bin 29783 -> 26369 bytes .../github-copilot-no-models-dark.png | Bin 28804 -> 25339 bytes .../github-copilot-signed-in-dark.png | Bin 27488 -> 24322 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png index d460f221bcb98494f30a847c45116c6b13c2085f..5af7f978763d862464a7e229e1c2b438a7cbb146 100644 GIT binary patch literal 26369 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 literal 29783 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

&vwf?(lDWH`9Mrhj+-6p=}h$hojM71F+C8fCAfH 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


)oOW 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 Date: Mon, 12 Oct 2026 09:14:38 -0700 Subject: [PATCH 12/33] docs(copilot): document the context window when the host reports no limit When a model reports no usable input limit, the shown and enforced window is the fallback capped by the curated limit. Clarify the README and pin that the handler condenses against the same window. --- README.md | 3 +- .../__tests__/github-copilot.spec.ts | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 000fabd0fe..c3cd6bb642 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ Capabilities come from what VS Code reports for each Copilot model, so new model changes, and nothing about a model is saved into your settings where it could go stale. - **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one - is known. Before the host reports, the curated limit is shown, matching what Zoo Code enforces. + is known. Before the host reports, or if it reports no usable limit, the limit shown matches what + Zoo Code enforces. - **Images:** read only from the capability the host reports for each model. Until it reports, or if it reports nothing, the model shows **Image support not reported** instead of being assumed supported or unsupported, and Zoo Code will not send images to it. Images are sent as image data, not text diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index db54d0c793..44451f7f8d 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -414,6 +414,47 @@ describe("Copilot model capabilities", () => { openAiModelInfoSaneDefaults.contextWindow, ) }) + + describe("when the host reports no usable limit for a model the catalog knows", () => { + const fallback = openAiModelInfoSaneDefaults.contextWindow + const catalog = Object.entries(vscodeLlmModels) + const above = catalog.find(([, entry]) => entry.maxInputTokens > fallback) + const below = catalog.find(([, entry]) => entry.maxInputTokens < fallback) + + it.each([0, Number.NaN, -1])("assumes the fallback, capped by the curated limit, for %s", (reported) => { + expect(above).toBeDefined() + const [family, entry] = above! + + const { contextWindow } = getVsCodeLmModelInfo(liveModel({ family, maxInputTokens: reported })) + + expect(contextWindow).toBe(fallback) + expect(contextWindow).toBeLessThan(entry.maxInputTokens) + }) + + it("keeps a curated limit that is already below the fallback", () => { + expect(below).toBeDefined() + const [family, entry] = below! + + expect(getVsCodeLmModelInfo(liveModel({ family, maxInputTokens: 0 })).contextWindow).toBe( + entry.maxInputTokens, + ) + }) + + it("is the window the handler enforces and condenses against, not a larger curated one", async () => { + const [family] = above! + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + liveModel({ family, maxInputTokens: 0 }), + ] as never) + const handler = new GitHubCopilotHandler({}) + try { + await handler.initializeClient() + expect(handler.getCondenseContextWindow()).toBe(fallback) + expect(handler.getModel().info.contextWindow).toBe(fallback) + } finally { + handler.dispose() + } + }) + }) }) describe("requests", () => { From ecaa7ff878d4de729e775d122d14be79661b9f6b Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 10:18:46 -0700 Subject: [PATCH 13/33] fix(copilot): address pre-merge checks Restore ClineProvider.ts to match main so the PR no longer touches provider registration, and cover the registry, superseded sign-in callbacks, unreachable broadcast targets, and unreported vision capabilities. --- .../__tests__/github-copilot.spec.ts | 8 +++ src/core/webview/ClineProvider.ts | 1 + .../webview/__tests__/ClineProvider.spec.ts | 42 ++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 55 ++++++++++++++++++ .../__tests__/copilotModelBroadcast.spec.ts | 56 +++++++++++++++++++ 5 files changed, 162 insertions(+) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 44451f7f8d..e68214b64c 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -369,6 +369,14 @@ describe("Copilot model capabilities", () => { expect(getVsCodeLmModelInfo(liveModel()).supportsImages).toBeUndefined() }) + it.each([ + ["a capabilities object that says nothing about vision", { supportsToolCalling: true }], + ["an empty capabilities object", {}], + ["vision reported as something other than a boolean", { supportsImageToText: "yes", imageInput: 1 }], + ])("leaves vision unset for %s", (_label, capabilities) => { + expect(getVsCodeLmModelInfo(liveModel({ capabilities })).supportsImages).toBeUndefined() + }) + it("reports no vision when this host cannot construct image parts", () => { const parts = dataPart() const original = parts.image diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6ed51df84e..cdd0975c5d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -346,6 +346,7 @@ export class ClineProvider ) ClineProvider.activeInstances.add(this) + this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 29b823cf50..31d658ff52 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -597,6 +597,48 @@ describe("ClineProvider", () => { expect(provider.isViewVisible).toBe(false) }) + describe("instance registry", () => { + let originalInstances: Set + + beforeEach(() => { + originalInstances = ClineProvider["activeInstances"] + ClineProvider["activeInstances"] = new Set() + }) + + afterEach(() => { + ClineProvider["activeInstances"] = originalInstances + }) + + const newProvider = () => + new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + provider.webviewFocusTracker, + ) + + it("lists a provider from the moment it is constructed, so extension-wide broadcasts reach it", () => { + const created = newProvider() + expect(ClineProvider.getAllInstances()).toEqual([created]) + }) + + it("lists every provider that is open, in the sidebar or an editor tab", () => { + const first = newProvider() + const second = newProvider() + expect(ClineProvider.getAllInstances()).toEqual([first, second]) + }) + + it("stops listing a provider once it is disposed, so broadcasts do not reach a closed view", async () => { + const kept = newProvider() + const closed = newProvider() + + await closed.dispose() + + expect(ClineProvider.getAllInstances()).toEqual([kept]) + }) + }) + describe("Add to Context destination", () => { let originalInstances: Set let tabProvider: ClineProvider diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d511f4379a..99cd6341b8 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -318,6 +318,48 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + it("drops the early account update of a sign-in that a newer one has superseded", async () => { + let supersededCallback!: (account: string) => Promise + let finishSupersededSignIn!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce((onAuthenticated) => { + supersededCallback = onAuthenticated! + return new Promise((resolve) => (finishSupersededSignIn = resolve)) + }) + .mockResolvedValueOnce({ account: "New User", models: [] }) + + const superseded = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + await supersededCallback("Old User") + finishSupersededSignIn({ account: "Old User", models: [] }) + await superseded + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + }) + }) + + it("publishes the early account update of a sign-in that is still the newest", async () => { + let callback!: (account: string) => Promise + let finish!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation((onAuthenticated) => { + callback = onAuthenticated! + return new Promise((resolve) => (finish = resolve)) + }) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + await callback("Test User") + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + }) + finish({ account: "Test User", models: [] }) + await signIn + }) + it("reports no account as null, so the webview clears a stale sign-in rather than keeping it", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) @@ -446,6 +488,19 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + it("reports a model discovery failure that is not an Error as text", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockRejectedValue("quota exceeded") + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + error: "quota exceeded", + }) + }) + it("shows model discovery errors instead of silently returning an empty list", async () => { vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockRejectedValue(new Error("Model access denied")) diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts index 5a9983a4d1..4f02fcd348 100644 --- a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -135,6 +135,62 @@ describe("registerCopilotModelBroadcast", () => { expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot state: auth down") }) + describe("a view that cannot be reached", () => { + // The refresh runs from a timer, so a rejection that escaped it would be unhandled. + const unhandled = vi.fn() + beforeEach(() => { + unhandled.mockClear() + process.on("unhandledRejection", unhandled) + }) + afterEach(() => void process.off("unhandledRejection", unhandled)) + + it("is logged and never escapes the refresh", async () => { + const broken = { postMessageToWebview: vi.fn().mockRejectedValue(new Error("webview disposed")) } + const { notify, log } = setup({ targets: [broken] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(broken.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledExactlyOnceWith("Failed to refresh GitHub Copilot state: webview disposed") + expect(unhandled).not.toHaveBeenCalled() + }) + + it("does not stop the other views from receiving the update", async () => { + const broken = { postMessageToWebview: vi.fn().mockRejectedValue(new Error("webview disposed")) } + const healthy = newTarget() + const { notify } = setup({ targets: [broken, healthy] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(healthy.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: "octocat", + }), + ) + expect(unhandled).not.toHaveBeenCalled() + }) + + it("is retried on the next change, since one failed delivery does not disable later refreshes", async () => { + const flaky = { + postMessageToWebview: vi + .fn() + .mockRejectedValueOnce(new Error("not ready")) + .mockResolvedValue(undefined), + } + const { notify } = setup({ targets: [flaky] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(flaky.postMessageToWebview).toHaveBeenCalledTimes(2) + }) + }) + it("logs a failure that is not an Error as text", async () => { const { notify, log } = setup({ fetchModels: vi.fn().mockRejectedValue("plain failure") }) From 342183ece3eb7920492ddf5c984cc4a65a953b92 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 10:48:26 -0700 Subject: [PATCH 14/33] fix(cli): read the GitHub Copilot model from its saved selector Copilot profiles keep the selected model in vsCodeLmModelSelector, not apiModelId, so the CLI looked up the wrong id and fell back to the default context window. --- .../utils/__tests__/context-window.test.ts | 42 +++++++++++++++++++ apps/cli/src/lib/utils/context-window.ts | 3 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/lib/utils/__tests__/context-window.test.ts b/apps/cli/src/lib/utils/__tests__/context-window.test.ts index 4347292166..b7f084f854 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -23,6 +23,48 @@ describe("getContextWindow", () => { expect(getContextWindow(routerModels, config)).toBe(123_456) }) + describe("GitHub Copilot", () => { + const provider = providerIdentifiers.githubCopilot + const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } } + + it("takes the model from the saved selector, where Copilot keeps it", () => { + const config: ProviderSettings = { + apiProvider: provider, + vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, + } + + expect(getContextWindow(routerModels, config)).toBe(123_456) + }) + + it("ignores apiModelId, which Copilot profiles do not use", () => { + const config: ProviderSettings = { + apiProvider: provider, + apiModelId: "other-model", + vsCodeLmModelSelector: { vendor: "copilot", id: "selected-model" }, + } + + expect(getContextWindow(routerModels, config)).toBe(123_456) + }) + + it.each([ + ["no selector", undefined], + ["a selector that names a family but no id", { vendor: "copilot", family: "gpt-5.5" }], + ])("returns the default for %s", (_label, vsCodeLmModelSelector) => { + const config: ProviderSettings = { apiProvider: provider, vsCodeLmModelSelector } + + expect(getContextWindow(routerModels, config)).toBe(DEFAULT_CONTEXT_WINDOW) + }) + + it("returns the default when the selected model is not in the list", () => { + const config: ProviderSettings = { + apiProvider: provider, + vsCodeLmModelSelector: { vendor: "copilot", id: "unlisted-model" }, + } + + expect(getContextWindow(routerModels, config)).toBe(DEFAULT_CONTEXT_WINDOW) + }) + }) + it("uses apiModelId for providers without a specialized model field", () => { const config: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index e429627243..0c5b4247f7 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -60,6 +60,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.nanoGptModelId case providerIdentifiers.zooGateway: return config.zooGatewayModelId + case providerIdentifiers.githubCopilot: + return config.vsCodeLmModelSelector?.id case providerIdentifiers.anthropic: case providerIdentifiers.bedrock: case providerIdentifiers.baseten: @@ -91,7 +93,6 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { case retiredProviderIdentifiers.ioIntelligence: case retiredProviderIdentifiers.roo: case providerIdentifiers.vscodeLm: - case providerIdentifiers.githubCopilot: case providerIdentifiers.fakeAi: case undefined: return config.apiModelId From ea009ef773f65414864e0cfd5d813b3cd9add4f9 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 11:03:42 -0700 Subject: [PATCH 15/33] fix(copilot): send a consent justification with single-shot completions completePrompt sent no request options, so the host's consent prompt for prompt enhancement did not name Zoo Code. Add an overridable getCompletionRequestOptions hook; the generic VS Code LM provider still sends none. --- .../__tests__/github-copilot.spec.ts | 20 +++++++++++++++++++ src/api/providers/github-copilot.ts | 6 ++++++ src/api/providers/vscode-lm.ts | 7 ++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index e68214b64c..55bfb6fcde 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -542,6 +542,26 @@ describe("GitHubCopilotHandler", () => { } }) + describe("completePrompt", () => { + it("sends a justification so the host's consent prompt names Zoo Code", async () => { + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("enhanced") + })(), + })) + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("improve this")).resolves.toBe("enhanced") + } finally { + handler.dispose() + } + + const requestOptions = mockLanguageModelChat.sendRequest.mock.calls[0][1] + expect(requestOptions.justification).toContain("Zoo Code") + expect(requestOptions.justification).toContain(mockLanguageModelChat.name) + }) + }) + describe("input limits", () => { const copilotModel = (overrides: Record = {}) => ({ ...mockLanguageModelChat, diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts index ecd69d2727..bcf515c9cd 100644 --- a/src/api/providers/github-copilot.ts +++ b/src/api/providers/github-copilot.ts @@ -41,6 +41,12 @@ export class GitHubCopilotHandler extends VsCodeLmHandler { return vscodeLlmBaselineModelInfo } + protected override getCompletionRequestOptions(client: vscode.LanguageModelChat) { + return { + justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, + } + } + protected override supportsImageInput(info: ModelInfo): boolean { return info.supportsImages === true } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 32dbc54791..1305d12111 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1496,12 +1496,17 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return this.getModel().info.contextWindow } + /** Request options for single-shot completions; the generic provider sends none. */ + protected getCompletionRequestOptions(_client: vscode.LanguageModelChat): vscode.LanguageModelChatRequestOptions { + return {} + } + async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { try { const client = await this.getClient() const response = await client.sendRequest( [vscode.LanguageModelChatMessage.User(prompt)], - {}, + this.getCompletionRequestOptions(client), new vscode.CancellationTokenSource().token, ) let result = "" From de8d0864e21babc07ad90ea2f679a8a4c300306b Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 11:34:14 -0700 Subject: [PATCH 16/33] test(copilot): fix vscode-lm spec for merge check --- src/api/providers/__tests__/vscode-lm.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 789acf6b91..b24284dde4 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -161,6 +161,41 @@ describe("VsCodeLmHandler", () => { // The constructor triggers initializeClient which calls selectChatModels expect(vscode.lm.selectChatModels).toHaveBeenCalled() }) + + describe("when the initial model lookup fails", () => { + let deferred: VsCodeLmHandler + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}) + vi.spyOn(console, "debug").mockImplementation(() => {}) + ;(vscode.lm.selectChatModels as Mock).mockRejectedValueOnce(new Error("models not ready")) + deferred = new VsCodeLmHandler(defaultOptions) + }) + + afterEach(() => { + deferred.dispose() + vi.restoreAllMocks() + }) + + it("does not throw from the constructor and logs that setup is deferred", async () => { + expect(deferred["client"]).toBeNull() + await vi.waitFor(() => + expect(console.debug).toHaveBeenCalledWith( + "Zoo Code : Initial client setup failed; retrying on first use:", + expect.objectContaining({ message: expect.stringContaining("models not ready") }), + ), + ) + expect(deferred["client"]).toBeNull() + }) + + it("creates the client on first use once the lookup succeeds", async () => { + await vi.waitFor(() => expect(console.debug).toHaveBeenCalled()) + ;(vscode.lm.selectChatModels as Mock).mockResolvedValue([{ ...mockLanguageModelChat }]) + + await expect(deferred["getClient"]()).resolves.toMatchObject({ id: mockLanguageModelChat.id }) + expect(deferred["client"]).not.toBeNull() + }) + }) }) describe("createClient", () => { From c6c97499fb57d865a0959ac66b772ebadd637a39 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 12:08:02 -0700 Subject: [PATCH 17/33] test(copilot): fix extension spec for merge check --- src/__tests__/extension.spec.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 889f780c9d..abcd3c28a0 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -477,6 +477,35 @@ describe("extension.ts", () => { }, ) + test("registers the Copilot model broadcast against every provider instance and the output channel", async () => { + vi.resetModules() + const disposable = { dispose: vi.fn() } + const registerCopilotModelBroadcast = vi.fn().mockReturnValue(disposable) + vi.doMock("../services/language-models/copilotModelBroadcast", () => ({ registerCopilotModelBroadcast })) + try { + const { ClineProvider } = await import("../core/webview/ClineProvider") + const vscode = await import("vscode") + const { activate } = await import("../extension") + + await activate(mockContext) + + expect(registerCopilotModelBroadcast).toHaveBeenCalledTimes(1) + const { getTargets, log } = registerCopilotModelBroadcast.mock.calls[0][0] + + const instances = [{ id: "first" }, { id: "second" }] + vi.mocked(ClineProvider.getAllInstances).mockReturnValue(instances as never) + expect(getTargets()).toBe(instances) + + const outputChannel = vi.mocked(vscode.window.createOutputChannel).mock.results[0].value + log("copilot broadcast message") + expect(outputChannel.appendLine).toHaveBeenCalledWith("copilot broadcast message") + + expect(mockContext.subscriptions).toContain(disposable) + } finally { + vi.doUnmock("../services/language-models/copilotModelBroadcast") + } + }) + test("does not call dotenv.config when optional .env does not exist", async () => { vi.resetModules() vi.clearAllMocks() From 4780ed95db1c03675798d04e4def761d14739ae1 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 12:37:38 -0700 Subject: [PATCH 18/33] fix(copilot): dispose completion resources and test Copilot Chat activation --- .../__tests__/github-copilot.spec.ts | 73 +++++++++++++++++++ src/api/providers/vscode-lm.ts | 7 +- src/utils/__tests__/enhance-prompt.spec.ts | 12 +++ src/utils/single-completion-handler.ts | 17 +++-- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 55bfb6fcde..c000a82b3c 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -140,6 +140,41 @@ describe("connectGitHubCopilot", () => { expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) }) + it("activates Copilot Chat after sign-in so model discovery can see its models", async () => { + const activate = vi.fn(async () => { + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ activate } as unknown as vscode.Extension) + vi.mocked(vscode.authentication.getSession).mockResolvedValue({ + id: "test-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "test-account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + await expect(connectGitHubCopilot()).resolves.toEqual({ account: "Test User", models: [] }) + + expect(vscode.extensions.getExtension).toHaveBeenCalledWith("GitHub.copilot-chat") + expect(activate).toHaveBeenCalledOnce() + expect(vscode.lm.selectChatModels).toHaveBeenCalledOnce() + }) + + it("still lists models when Copilot Chat is not installed", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined) + vi.mocked(vscode.authentication.getSession).mockResolvedValue({ + id: "test-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "test-account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + await expect(connectGitHubCopilot()).resolves.toEqual({ account: "Test User", models: [] }) + expect(vscode.extensions.getExtension).toHaveBeenCalledWith("GitHub.copilot-chat") + expect(vscode.lm.selectChatModels).toHaveBeenCalledOnce() + }) + it("rejects an incomplete sign-in rather than reporting a connected account", async () => { vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce(undefined) await expect(connectGitHubCopilot()).rejects.toThrow("GitHub sign-in did not complete") @@ -560,6 +595,44 @@ describe("GitHubCopilotHandler", () => { expect(requestOptions.justification).toContain("Zoo Code") expect(requestOptions.justification).toContain(mockLanguageModelChat.name) }) + + it("disposes each request's cancellation source and the handler's configuration listener", async () => { + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("enhanced") + })(), + })) + + for (let i = 0; i < 3; i++) { + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("improve this")).resolves.toBe("enhanced") + } finally { + handler.dispose() + } + } + + const sources = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(sources).toHaveLength(3) + for (const source of sources) expect(source.dispose).toHaveBeenCalledOnce() + + const subscriptions = vi.mocked(vscode.workspace.onDidChangeConfiguration).mock.results.map((r) => r.value) + expect(subscriptions).toHaveLength(3) + for (const subscription of subscriptions) expect(subscription.dispose).toHaveBeenCalledOnce() + }) + + it("disposes the cancellation source when the request fails", async () => { + mockLanguageModelChat.sendRequest.mockRejectedValueOnce(new Error("boom")) + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("improve this")).rejects.toThrow("boom") + } finally { + handler.dispose() + } + + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(source.dispose).toHaveBeenCalledOnce() + }) }) describe("input limits", () => { diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 1305d12111..9fec9df7d4 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1501,13 +1501,14 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return {} } - async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const cancellation = new vscode.CancellationTokenSource() try { const client = await this.getClient() const response = await client.sendRequest( [vscode.LanguageModelChatMessage.User(prompt)], this.getCompletionRequestOptions(client), - new vscode.CancellationTokenSource().token, + cancellation.token, ) let result = "" for await (const chunk of response.stream) { @@ -1521,6 +1522,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan throw new Error(`VSCode LM completion error: ${error.message}`) } throw error + } finally { + cancellation.dispose() } } } diff --git a/src/utils/__tests__/enhance-prompt.spec.ts b/src/utils/__tests__/enhance-prompt.spec.ts index 755e9df7d7..0b0e906d52 100644 --- a/src/utils/__tests__/enhance-prompt.spec.ts +++ b/src/utils/__tests__/enhance-prompt.spec.ts @@ -46,6 +46,18 @@ describe("enhancePrompt", () => { expect((handler as any).completePrompt).toHaveBeenCalledWith(`Test prompt`, undefined) }) + it("disposes a disposable handler after every completion, including failures", async () => { + const dispose = vi.fn() + const completePrompt = vi.fn().mockResolvedValueOnce("ok").mockRejectedValueOnce(new Error("boom")) + vi.mocked(buildApiHandler).mockReturnValue({ completePrompt, dispose } as unknown as ReturnType< + typeof buildApiHandler + >) + + await expect(singleCompletionHandler(mockApiConfig, "a")).resolves.toBe("ok") + await expect(singleCompletionHandler(mockApiConfig, "b")).rejects.toThrow("boom") + expect(dispose).toHaveBeenCalledTimes(2) + }) + it("enhances prompt using custom enhancement prompt when provided", async () => { const customEnhancePrompt = "You are a custom prompt enhancer" const customEnhancePromptWithTemplate = customEnhancePrompt + "\n\n${userInput}" diff --git a/src/utils/single-completion-handler.ts b/src/utils/single-completion-handler.ts index 4890b38471..0d4e96b521 100644 --- a/src/utils/single-completion-handler.ts +++ b/src/utils/single-completion-handler.ts @@ -20,10 +20,17 @@ export async function singleCompletionHandler( const handler = buildApiHandler(apiConfiguration) - // Check if handler supports single completions - if (!("completePrompt" in handler)) { - throw new Error("The selected API provider does not support prompt enhancement") - } + try { + // Check if handler supports single completions + if (!("completePrompt" in handler)) { + throw new Error("The selected API provider does not support prompt enhancement") + } - return (handler as SingleCompletionHandler).completePrompt(promptText, options) + return await (handler as SingleCompletionHandler).completePrompt(promptText, options) + } finally { + // Handlers such as VS Code LM / GitHub Copilot hold listeners that must be released. + if ("dispose" in handler && typeof handler.dispose === "function") { + handler.dispose() + } + } } From 5c9854f0e8f7ec4b38e9e4d09e97258173729899 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 13:20:34 -0700 Subject: [PATCH 19/33] fix(copilot): dispose task API handlers and honor completion abort/timeout --- src/api/index.ts | 10 +++ .../__tests__/github-copilot.spec.ts | 67 +++++++++++++++++++ src/api/providers/vscode-lm.ts | 14 ++++ src/core/task/Task.ts | 24 ++++++- src/core/task/__tests__/Task.spec.ts | 41 ++++++++++++ src/utils/__tests__/enhance-prompt.spec.ts | 20 ++++-- src/utils/single-completion-handler.ts | 6 +- 7 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index c302367996..9292281d58 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -185,6 +185,16 @@ export interface ApiHandler { getModelCacheScope(): ModelCacheScope } +/** + * Releases host resources a handler owns (for example the VS Code LM / GitHub Copilot configuration + * listener). Handlers without a `dispose()` method hold nothing that needs releasing. + */ +export function disposeApiHandler(handler: ApiHandler | undefined): void { + if (handler && "dispose" in handler && typeof handler.dispose === "function") { + handler.dispose() + } +} + export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index c000a82b3c..9a78a17681 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -621,6 +621,73 @@ describe("GitHubCopilotHandler", () => { for (const subscription of subscriptions) expect(subscription.dispose).toHaveBeenCalledOnce() }) + it("cancels the VS Code request when the caller aborts, and stops listening afterwards", async () => { + let token: vscode.CancellationToken | undefined + mockLanguageModelChat.sendRequest.mockImplementation(async (_m, _o, t: vscode.CancellationToken) => { + token = t + return { stream: (async function* () {})() } + }) + const controller = new AbortController() + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await handler.completePrompt("improve this", { abortSignal: controller.signal }) + } finally { + handler.dispose() + } + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(token).toBe(source.token) + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)) + controller.abort() + expect(source.cancel).not.toHaveBeenCalled() + }) + + it("refuses to send when the caller already aborted", async () => { + const controller = new AbortController() + controller.abort() + vi.mocked(vscode.CancellationTokenSource).mockImplementationOnce(function () { + const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() } + return { + token, + cancel: vi.fn(() => (token.isCancellationRequested = true)), + dispose: vi.fn(), + } as unknown as vscode.CancellationTokenSource + }) + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("x", { abortSignal: controller.signal })).rejects.toThrow( + "Operation cancelled", + ) + } finally { + handler.dispose() + } + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("cancels the VS Code request once the timeout elapses", async () => { + vi.useFakeTimers() + try { + let release!: () => void + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + await new Promise((r) => (release = r)) + })(), + })) + const handler = new GitHubCopilotHandler(defaultOptions) + const pending = handler.completePrompt("x", { timeoutMs: 1000 }) + await vi.advanceTimersByTimeAsync(0) + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(source.cancel).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1000) + expect(source.cancel).toHaveBeenCalledOnce() + release() + await pending + handler.dispose() + } finally { + vi.useRealTimers() + } + }) + it("disposes the cancellation source when the request fails", async () => { mockLanguageModelChat.sendRequest.mockRejectedValueOnce(new Error("boom")) const handler = new GitHubCopilotHandler(defaultOptions) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 9fec9df7d4..340c30872e 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1503,7 +1503,19 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { const cancellation = new vscode.CancellationTokenSource() + const cancel = () => cancellation.cancel() + const abortSignal = options?.abortSignal + const timeoutMs = options?.timeoutMs ?? this.timeoutMs + const timer = timeoutMs > 0 ? setTimeout(cancel, timeoutMs) : undefined + if (abortSignal?.aborted) { + cancel() + } else { + abortSignal?.addEventListener("abort", cancel, { once: true }) + } try { + if (cancellation.token.isCancellationRequested) { + throw new vscode.CancellationError() + } const client = await this.getClient() const response = await client.sendRequest( [vscode.LanguageModelChatMessage.User(prompt)], @@ -1523,6 +1535,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } throw error } finally { + if (timer) clearTimeout(timer) + abortSignal?.removeEventListener("abort", cancel) cancellation.dispose() } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fdcc740998..4309ba32e5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -62,7 +62,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" // api -import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" +import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler, disposeApiHandler } from "../../api" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error" @@ -405,6 +405,8 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler + /** Handlers replaced while a stream may still be reading from them; disposed with the task. */ + private retiredApis: ApiHandler[] = [] private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -2459,9 +2461,17 @@ export class Task extends EventEmitter implements TaskLike { * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { - // Update the configuration and rebuild the API handler + // Update the configuration and rebuild the API handler. Handlers such as + // VS Code LM / GitHub Copilot own host listeners, so release the old one; + // a stream still reading from it keeps it alive until the task is disposed. + const previousApi = this.api this.apiConfiguration = newApiConfiguration this.api = buildApiHandler(this.apiConfiguration) + if (this.isStreaming) { + this.retiredApis.push(previousApi) + } else { + disposeApiHandler(previousApi) + } // Refresh only the soft tool-repetition limit from the new profile so an // active task immediately enforces it. The task-level @@ -3515,6 +3525,16 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() + // Release handler-owned host resources (e.g. VS Code configuration listeners). + for (const api of [...this.retiredApis, this.api]) { + try { + disposeApiHandler(api) + } catch (error) { + console.error("Error disposing API handler:", error) + } + } + this.retiredApis = [] + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 85325b39ff..6b8135b815 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1777,6 +1777,47 @@ describe("Cline", () => { }) }) + describe("API handler disposal", () => { + const disposableApi = (task: Task) => { + const dispose = vi.fn() + task.api = Object.assign(Object.create(task.api), { dispose }) + return dispose + } + + it("disposes the replaced handler when the configuration changes while idle", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const dispose = disposableApi(task) + + task.updateApiConfiguration({ ...mockApiConfig }) + expect(dispose).toHaveBeenCalledOnce() + + await task.dispose() + expect(dispose).toHaveBeenCalledOnce() + }) + + it("defers disposing a handler replaced mid-stream until the task is disposed", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const oldDispose = disposableApi(task) + task.isStreaming = true + + task.updateApiConfiguration({ ...mockApiConfig }) + expect(oldDispose).not.toHaveBeenCalled() + const newDispose = disposableApi(task) + + await task.dispose() + expect(oldDispose).toHaveBeenCalledOnce() + expect(newDispose).toHaveBeenCalledOnce() + }) + + it("disposes the current handler exactly once even when dispose is called twice", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const dispose = disposableApi(task) + + await Promise.all([task.dispose(), task.dispose()]) + expect(dispose).toHaveBeenCalledOnce() + }) + }) + describe("task-local configuration isolation", () => { it("uses the task mode and API configuration when focused provider state differs", async () => { const taskApiConfiguration: ProviderSettings = { diff --git a/src/utils/__tests__/enhance-prompt.spec.ts b/src/utils/__tests__/enhance-prompt.spec.ts index 0b0e906d52..d83e5b5e01 100644 --- a/src/utils/__tests__/enhance-prompt.spec.ts +++ b/src/utils/__tests__/enhance-prompt.spec.ts @@ -10,6 +10,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" // Mock the API handler vi.mock("../../api", () => ({ buildApiHandler: vi.fn(), + disposeApiHandler: vi.fn((handler?: { dispose?: () => void }) => handler?.dispose?.()), })) describe("enhancePrompt", () => { @@ -46,16 +47,21 @@ describe("enhancePrompt", () => { expect((handler as any).completePrompt).toHaveBeenCalledWith(`Test prompt`, undefined) }) - it("disposes a disposable handler after every completion, including failures", async () => { - const dispose = vi.fn() - const completePrompt = vi.fn().mockResolvedValueOnce("ok").mockRejectedValueOnce(new Error("boom")) - vi.mocked(buildApiHandler).mockReturnValue({ completePrompt, dispose } as unknown as ReturnType< - typeof buildApiHandler - >) + it("disposes each handler it builds, after success and after failure", async () => { + const succeeding = { completePrompt: vi.fn().mockResolvedValue("ok"), dispose: vi.fn() } + const failing = { completePrompt: vi.fn().mockRejectedValue(new Error("boom")), dispose: vi.fn() } + type Handler = ReturnType + vi.mocked(buildApiHandler) + .mockReturnValueOnce(succeeding as unknown as Handler) + .mockReturnValueOnce(failing as unknown as Handler) await expect(singleCompletionHandler(mockApiConfig, "a")).resolves.toBe("ok") + expect(succeeding.dispose).toHaveBeenCalledOnce() + expect(failing.dispose).not.toHaveBeenCalled() + await expect(singleCompletionHandler(mockApiConfig, "b")).rejects.toThrow("boom") - expect(dispose).toHaveBeenCalledTimes(2) + expect(failing.dispose).toHaveBeenCalledOnce() + expect(succeeding.dispose).toHaveBeenCalledOnce() }) it("enhances prompt using custom enhancement prompt when provided", async () => { diff --git a/src/utils/single-completion-handler.ts b/src/utils/single-completion-handler.ts index 0d4e96b521..148f440461 100644 --- a/src/utils/single-completion-handler.ts +++ b/src/utils/single-completion-handler.ts @@ -1,6 +1,6 @@ import type { ProviderSettings } from "@roo-code/types" -import { buildApiHandler, SingleCompletionHandler, type CompletePromptOptions } from "../api" +import { buildApiHandler, disposeApiHandler, SingleCompletionHandler, type CompletePromptOptions } from "../api" /** * Enhances a prompt using the configured API without creating a full Cline instance or task history. @@ -29,8 +29,6 @@ export async function singleCompletionHandler( return await (handler as SingleCompletionHandler).completePrompt(promptText, options) } finally { // Handlers such as VS Code LM / GitHub Copilot hold listeners that must be released. - if ("dispose" in handler && typeof handler.dispose === "function") { - handler.dispose() - } + disposeApiHandler(handler) } } From 31692bd942de05c54a3d66123a023e92c6fb7714 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 13:30:40 -0700 Subject: [PATCH 20/33] style: format Task.spec.ts --- src/core/task/__tests__/Task.spec.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6b8135b815..11a5ef8a8b 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1785,7 +1785,12 @@ describe("Cline", () => { } it("disposes the replaced handler when the configuration changes while idle", async () => { - const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "t", + startTask: false, + }) const dispose = disposableApi(task) task.updateApiConfiguration({ ...mockApiConfig }) @@ -1796,7 +1801,12 @@ describe("Cline", () => { }) it("defers disposing a handler replaced mid-stream until the task is disposed", async () => { - const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "t", + startTask: false, + }) const oldDispose = disposableApi(task) task.isStreaming = true @@ -1810,7 +1820,12 @@ describe("Cline", () => { }) it("disposes the current handler exactly once even when dispose is called twice", async () => { - const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "t", + startTask: false, + }) const dispose = disposableApi(task) await Promise.all([task.dispose(), task.dispose()]) From 3631d60edc973740dc419d15bfdf9c6974285352 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 13:39:54 -0700 Subject: [PATCH 21/33] test(copilot): yield from the delayed stream to satisfy require-yield --- src/api/providers/__tests__/github-copilot.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 9a78a17681..2469914dcc 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -671,6 +671,7 @@ describe("GitHubCopilotHandler", () => { mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ stream: (async function* () { await new Promise((r) => (release = r)) + yield new vscode.LanguageModelTextPart("late") })(), })) const handler = new GitHubCopilotHandler(defaultOptions) From 757082fb910d7161b5039b854bb13cc019393c63 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 13:52:23 -0700 Subject: [PATCH 22/33] fix(task): dispose API handlers without depending on the api module export --- src/core/task/Task.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4309ba32e5..d725b4bfd4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -62,7 +62,14 @@ import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" // api -import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler, disposeApiHandler } from "../../api" +import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" + +/** Releases host resources a handler owns, such as the VS Code LM / GitHub Copilot configuration listener. */ +function disposeApiHandler(handler: ApiHandler | undefined): void { + if (handler && "dispose" in handler && typeof handler.dispose === "function") { + handler.dispose() + } +} import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error" From 6803dc6059403ee6f291c5545673df70dbad67de Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 14:29:13 -0700 Subject: [PATCH 23/33] fix(task): release replaced API handlers when in-flight work settles A handler replaced by a profile switch was kept until task disposal when the switch landed mid-stream, and disposed immediately when it landed during request setup (isStreaming still false) even though that request still held it. Track in-flight API work instead: a request from setup through retries, the stream, and its background usage drain, plus manual condensing. Replaced handlers are disposed when that work settles, with task disposal as the fallback. The main loop closes the request generator on failure so its release runs. Tighten completePrompt tests: abort while the stream is pending, assert the same listener reference is added and removed, and cover the disabled timeout and non-Error paths. Cover disposeApiHandler directly. --- src/api/__tests__/index.spec.ts | 21 ++- .../__tests__/github-copilot.spec.ts | 79 ++++++++- src/core/task/Task.ts | 102 +++++++++-- src/core/task/__tests__/Task.spec.ts | 159 +++++++++++++++++- 4 files changed, 331 insertions(+), 30 deletions(-) diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 984bab2c96..525e46a096 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -45,7 +45,7 @@ import { import type { RouterName } from "../../shared/api" -import { buildApiHandler } from "../index" +import { buildApiHandler, disposeApiHandler, type ApiHandler } from "../index" import { AnthropicHandler, AnthropicVertexHandler, @@ -130,6 +130,25 @@ const expectedHandlerEntries = Object.entries(expectedHandlers) as Array< [Exclude, HandlerConstructor] > +describe("disposeApiHandler", () => { + const baseHandler = () => buildApiHandler({ apiProvider: providerIdentifiers.fakeAi }) + + it("calls dispose on a handler that owns resources", () => { + const dispose = vi.fn() + const handler: ApiHandler = Object.assign(Object.create(baseHandler()), { dispose }) + + disposeApiHandler(handler) + + expect(dispose).toHaveBeenCalledOnce() + }) + + it("ignores handlers without dispose, a non-function dispose, and a missing handler", () => { + expect(() => disposeApiHandler(undefined)).not.toThrow() + expect(() => disposeApiHandler(Object.create(baseHandler()))).not.toThrow() + expect(() => disposeApiHandler(Object.assign(Object.create(baseHandler()), { dispose: true }))).not.toThrow() + }) +}) + describe("buildApiHandler", () => { it.each(expectedHandlerEntries)("returns the expected handler for %s", (apiProvider, Handler) => { const handler = buildApiHandler({ apiProvider }) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 2469914dcc..44e50b220e 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -621,13 +621,48 @@ describe("GitHubCopilotHandler", () => { for (const subscription of subscriptions) expect(subscription.dispose).toHaveBeenCalledOnce() }) - it("cancels the VS Code request when the caller aborts, and stops listening afterwards", async () => { + it("cancels the in-flight VS Code request when the caller aborts mid-stream", async () => { let token: vscode.CancellationToken | undefined + let streamStarted!: () => void + const started = new Promise((resolve) => (streamStarted = resolve)) + let release!: () => void mockLanguageModelChat.sendRequest.mockImplementation(async (_m, _o, t: vscode.CancellationToken) => { token = t - return { stream: (async function* () {})() } + return { + stream: (async function* () { + streamStarted() + await new Promise((resolve) => (release = resolve)) + yield new vscode.LanguageModelTextPart("late") + })(), + } }) const controller = new AbortController() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const pending = handler.completePrompt("improve this", { abortSignal: controller.signal }) + await started + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(token).toBe(source.token) + expect(source.cancel).not.toHaveBeenCalled() + + controller.abort() + expect(source.cancel).toHaveBeenCalledOnce() + + release() + await pending + } finally { + handler.dispose() + } + }) + + it("removes exactly the abort listener it added once the request settles", async () => { + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("done") + })(), + })) + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") const removeSpy = vi.spyOn(controller.signal, "removeEventListener") const handler = new GitHubCopilotHandler(defaultOptions) try { @@ -635,13 +670,49 @@ describe("GitHubCopilotHandler", () => { } finally { handler.dispose() } + + expect(addSpy).toHaveBeenCalledOnce() + const [addedType, addedListener] = addSpy.mock.calls[0] + expect(addedType).toBe("abort") + expect(removeSpy).toHaveBeenCalledOnce() + expect(removeSpy.mock.calls[0][0]).toBe("abort") + expect(removeSpy.mock.calls[0][1]).toBe(addedListener) + + // An abort after completion must no longer reach the disposed source. const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) - expect(token).toBe(source.token) - expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)) controller.abort() expect(source.cancel).not.toHaveBeenCalled() }) + it("arms no timer when the timeout is disabled", async () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + })(), + })) + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("x", { timeoutMs: 0 })).resolves.toBe("ok") + } finally { + handler.dispose() + setTimeoutSpy.mockRestore() + } + expect(setTimeoutSpy).not.toHaveBeenCalled() + }) + + it("rethrows a non-Error failure unchanged", async () => { + mockLanguageModelChat.sendRequest.mockRejectedValueOnce("plain failure") + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect(handler.completePrompt("x")).rejects.toBe("plain failure") + } finally { + handler.dispose() + } + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(source.dispose).toHaveBeenCalledOnce() + }) + it("refuses to send when the caller already aborted", async () => { const controller = new AbortController() controller.abort() diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d725b4bfd4..6099684058 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -412,8 +412,14 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler - /** Handlers replaced while a stream may still be reading from them; disposed with the task. */ - private retiredApis: ApiHandler[] = [] + /** + * Units of in-flight API work: a request from setup through its retries and + * background usage drain, or a manual condense. Work can hold any handler it + * read from `this.api`, so replaced handlers wait for the count to reach zero. + */ + private activeApiWork = 0 + /** Handlers replaced during in-flight work; disposed when that work settles, or with the task. */ + private retiredApis = new Set() private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -2469,16 +2475,13 @@ export class Task extends EventEmitter implements TaskLike { */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler. Handlers such as - // VS Code LM / GitHub Copilot own host listeners, so release the old one; - // a stream still reading from it keeps it alive until the task is disposed. + // VS Code LM / GitHub Copilot own host listeners, so the old one is + // released — immediately when idle, or when the last request, retry, + // background drain, or condense that leased it settles. const previousApi = this.api this.apiConfiguration = newApiConfiguration this.api = buildApiHandler(this.apiConfiguration) - if (this.isStreaming) { - this.retiredApis.push(previousApi) - } else { - disposeApiHandler(previousApi) - } + this.retireApi(previousApi) // Refresh only the soft tool-repetition limit from the new profile so an // active task immediately enforces it. The task-level @@ -2494,6 +2497,44 @@ export class Task extends EventEmitter implements TaskLike { this.toolRepetitionDetector.updateLimits(this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit) } + /** + * Marks the start of in-flight API work. The returned release is idempotent; + * the last release disposes every handler replaced while work was running. + */ + private beginApiWork(): () => void { + this.activeApiWork++ + let released = false + return () => { + if (released) return + released = true + this.activeApiWork-- + if (this.activeApiWork > 0) return + const retired = [...this.retiredApis] + this.retiredApis.clear() + for (const api of retired) { + this.safeDisposeApi(api) + } + } + } + + /** Disposes a replaced handler now, or once in-flight API work settles. */ + private retireApi(api: ApiHandler): void { + if (api === this.api) return + if (this.activeApiWork > 0) { + this.retiredApis.add(api) + } else { + this.safeDisposeApi(api) + } + } + + private safeDisposeApi(api: ApiHandler): void { + try { + disposeApiHandler(api) + } catch (error) { + console.error("Error disposing API handler:", error) + } + } + public async submitUserMessage( text: string, images?: string[], @@ -2560,6 +2601,15 @@ export class Task extends EventEmitter implements TaskLike { } public async condenseContext(): Promise { + const endApiWork = this.beginApiWork() + try { + await this.condenseContextWithActiveApi() + } finally { + endApiWork() + } + } + + private async condenseContextWithActiveApi(): Promise { // CRITICAL: Flush any pending tool results before condensing // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() @@ -3533,14 +3583,13 @@ export class Task extends EventEmitter implements TaskLike { this.cancelAssistantMessagePersistence() // Release handler-owned host resources (e.g. VS Code configuration listeners). - for (const api of [...this.retiredApis, this.api]) { - try { - disposeApiHandler(api) - } catch (error) { - console.error("Error disposing API handler:", error) - } + // This is the fallback for leases that never settle; later lease releases + // find nothing retired and so cannot dispose twice. + const apis = new Set([...this.retiredApis, this.api]) + this.retiredApis.clear() + for (const api of apis) { + this.safeDisposeApi(api) } - this.retiredApis = [] // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task @@ -4540,6 +4589,12 @@ export class Task extends EventEmitter implements TaskLike { console.error("Background usage collection failed:", error) }) } catch (error) { + // Close the request generator so its `finally` releases the API + // work it holds (a replaced handler is disposed once nothing uses + // it). Async-generator `return()` queues behind a pending `next()`, + // so this settles as soon as the aborted stream does. + Promise.resolve(stream.return?.(undefined)).catch(() => {}) + // Abandoned happens when extension is no longer waiting for the // Cline instance to finish aborting (error is thrown here when // any function in the for loop throws due to this.abort). @@ -5463,6 +5518,21 @@ export class Task extends EventEmitter implements TaskLike { public async *attemptApiRequest( retryAttempt: number = 0, options: { skipProviderRateLimit?: boolean; requestModelInfo?: ModelInfo } = {}, + ): ApiStream { + // Hold replaced handlers from request setup until the stream is fully + // consumed (including the background usage drain) or closed early. + // Retries recurse through this method and nest their own unit of work. + const endApiWork = this.beginApiWork() + try { + yield* this.attemptApiRequestWithActiveApi(retryAttempt, options) + } finally { + endApiWork() + } + } + + private async *attemptApiRequestWithActiveApi( + retryAttempt: number, + options: { skipProviderRateLimit?: boolean; requestModelInfo?: ModelInfo }, ): ApiStream { const state = await this.providerRef.deref()?.getState() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 11a5ef8a8b..49fc1b88bc 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -63,6 +63,9 @@ type TaskTestAccess = { messages: ApiMessage[], requestModelInfo: ModelInfo, ) => Array<{ role: string; content: unknown } | { type: "reasoning"; encrypted_content: string }> + attemptApiRequestWithActiveApi: () => AsyncGenerator + condenseContextWithActiveApi: () => Promise + retiredApis: Set } type TaskAskResult = Awaited> @@ -1800,23 +1803,161 @@ describe("Cline", () => { expect(dispose).toHaveBeenCalledOnce() }) - it("defers disposing a handler replaced mid-stream until the task is disposed", async () => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "t", - startTask: false, - }) + /** + * Replaces the request body behind `attemptApiRequest` with a stream the test + * drives, so the lifecycle wrapper runs for real without prompt/tool setup. + * `gate` holds the request in setup (before its first chunk) until opened. + */ + const controllableRequest = (task: Task) => { + let openGate!: () => void + const gate = new Promise((resolve) => (openGate = resolve)) + let finish!: () => void + const finished = new Promise((resolve) => (finish = resolve)) + vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation(async function* () { + await gate + yield { type: "text", text: "a" } + await finished + yield { type: "text", text: "b" } + }) + return { openGate, finish } + } + + const newTask = () => + new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) + + it("keeps a handler replaced during request setup alive until that request settles", async () => { + const task = newTask() + const { openGate, finish } = controllableRequest(task) const oldDispose = disposableApi(task) - task.isStreaming = true + + const request = task.attemptApiRequest() + const first = request.next() // enters setup and blocks on the gate, isStreaming still false + await Promise.resolve() + expect(task.isStreaming).toBe(false) task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() - const newDispose = disposableApi(task) + openGate() + await first + expect(oldDispose).not.toHaveBeenCalled() + + finish() + for await (const _ of request) { + // drain + } + expect(oldDispose).toHaveBeenCalledOnce() + + await task.dispose() + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("releases a handler replaced mid-stream when the stream is closed early, not at task disposal", async () => { + const task = newTask() + const { openGate } = controllableRequest(task) + const oldDispose = disposableApi(task) + + openGate() + const request = task.attemptApiRequest() + await request.next() + task.updateApiConfiguration({ ...mockApiConfig }) + expect(oldDispose).not.toHaveBeenCalled() + + await request.return(undefined) + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("does not accumulate handlers across repeated mid-stream profile switches", async () => { + const task = newTask() + const disposals: Array> = [] + + for (let i = 0; i < 3; i++) { + const { openGate, finish } = controllableRequest(task) + disposals.push(disposableApi(task)) + openGate() + const request = task.attemptApiRequest() + await request.next() + task.updateApiConfiguration({ ...mockApiConfig }) + finish() + for await (const _ of request) { + // drain + } + expect(disposals[i]).toHaveBeenCalledOnce() + } + + expect(getTaskTestAccess(task).retiredApis.size).toBe(0) + }) + + it("waits for every overlapping unit of work before disposing a replaced handler", async () => { + const task = newTask() + const first = controllableRequest(task) + const oldDispose = disposableApi(task) + first.openGate() + const requestA = task.attemptApiRequest() + await requestA.next() + + const second = controllableRequest(task) + second.openGate() + const requestB = task.attemptApiRequest() + await requestB.next() + + task.updateApiConfiguration({ ...mockApiConfig }) + await requestA.return(undefined) + expect(oldDispose).not.toHaveBeenCalled() + + await requestB.return(undefined) + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("disposes a handler retired by work that never settles when the task is disposed", async () => { + const task = newTask() + const { openGate } = controllableRequest(task) + const oldDispose = disposableApi(task) + openGate() + const request = task.attemptApiRequest() + await request.next() + + task.updateApiConfiguration({ ...mockApiConfig }) + const newDispose = disposableApi(task) await task.dispose() expect(oldDispose).toHaveBeenCalledOnce() expect(newDispose).toHaveBeenCalledOnce() + + // The request settling later must not dispose anything a second time. + await request.return(undefined) + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("keeps the handler alive for a manual condense started before the switch", async () => { + const task = newTask() + const oldDispose = disposableApi(task) + let release!: () => void + vi.spyOn(getTaskTestAccess(task), "condenseContextWithActiveApi").mockImplementation( + () => new Promise((resolve) => (release = resolve)), + ) + + const condensing = task.condenseContext() + task.updateApiConfiguration({ ...mockApiConfig }) + expect(oldDispose).not.toHaveBeenCalled() + + release() + await condensing + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("logs and continues when a replaced handler's dispose throws", async () => { + const task = newTask() + const error = new Error("dispose failed") + task.api = Object.assign(Object.create(task.api), { + dispose: vi.fn(() => { + throw error + }), + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(() => task.updateApiConfiguration({ ...mockApiConfig })).not.toThrow() + expect(consoleError).toHaveBeenCalledWith("Error disposing API handler:", error) + consoleError.mockRestore() }) it("disposes the current handler exactly once even when dispose is called twice", async () => { From fac9b6a04401bb26bf150345436a3b72b050d864 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 14:55:16 -0700 Subject: [PATCH 24/33] fix(vscode-lm): cancel streaming requests from the task's abort signal createMessage passed its own cancellation token to sendRequest and never connected metadata.abortSignal to it. When a task aborted while the host was waiting for the next chunk, the pending stream read never settled, so the request generator could not unwind and release the API handler it held. Cancel the request's token when the signal aborts (or before sending if it already has), report the resulting cancellation as one, and remove the listener on every exit path, including an early return(). --- .../__tests__/github-copilot.spec.ts | 112 ++++++++++++++++++ src/api/providers/vscode-lm.ts | 19 ++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 44e50b220e..9600887b0c 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -577,6 +577,118 @@ describe("GitHubCopilotHandler", () => { } }) + describe("createMessage cancellation", () => { + beforeEach(() => mockLanguageModelChat.countTokens.mockResolvedValue(10)) + + /** A stream whose next read stays pending until `release`, as when the host waits on a chunk. */ + const pendingStream = () => { + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let token: vscode.CancellationToken | undefined + mockLanguageModelChat.sendRequest.mockImplementation(async (_m, _o, t: vscode.CancellationToken) => { + token = t + return { + stream: (async function* () { + yield new vscode.LanguageModelTextPart("first") + await released + yield new vscode.LanguageModelTextPart("late") + })(), + } + }) + return { release, token: () => token } + } + + it("cancels the host request when the task aborts while a chunk is pending", async () => { + const stream = pendingStream() + const controller = new AbortController() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }) + await messages.next() + const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + expect(stream.token()).toBe(source.token) + expect(source.cancel).not.toHaveBeenCalled() + + controller.abort() + expect(source.cancel).toHaveBeenCalledOnce() + + stream.release() + await messages.return(undefined) + } finally { + handler.dispose() + } + }) + + it("cancels before sending when the task already aborted", async () => { + pendingStream() + const controller = new AbortController() + controller.abort() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }) + await messages.next() + const source = vi.mocked(vscode.CancellationTokenSource).mock.results.at(-1)?.value + expect(source.cancel).toHaveBeenCalled() + await messages.return(undefined) + } finally { + handler.dispose() + } + }) + + it("removes exactly the abort listener it added when the stream is closed early", async () => { + const stream = pendingStream() + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }) + await messages.next() + stream.release() + await messages.return(undefined) + } finally { + handler.dispose() + } + + expect(addSpy).toHaveBeenCalledOnce() + expect(removeSpy).toHaveBeenCalledOnce() + expect(removeSpy.mock.calls[0][0]).toBe("abort") + expect(removeSpy.mock.calls[0][1]).toBe(addSpy.mock.calls[0][1]) + }) + + it("removes the abort listener after the stream completes normally", async () => { + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("done") + })(), + })) + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await collectStream( + handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }), + ) + } finally { + handler.dispose() + } + expect(removeSpy.mock.calls[0]?.[1]).toBe(addSpy.mock.calls[0][1]) + }) + }) + describe("completePrompt", () => { it("sends a justification so the host's consent prompt names Zoo Code", async () => { mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 340c30872e..dbdca8faee 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1189,8 +1189,18 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan ...convertToVsCodeLmMessages(cleanedMessages, this.supportsImageInput(modelInfo)), ] - // Initialize cancellation token for the request - this.currentRequestCancellation = new vscode.CancellationTokenSource() + // Initialize cancellation token for the request, and let the caller's abort + // cancel the host request. Without this, a pending `response.stream` read + // keeps waiting after the task aborts, so the request never unwinds. + const requestCancellation = new vscode.CancellationTokenSource() + this.currentRequestCancellation = requestCancellation + const abortSignal = metadata?.abortSignal + const cancelOnAbort = () => requestCancellation.cancel() + if (abortSignal?.aborted) { + cancelOnAbort() + } else { + abortSignal?.addEventListener("abort", cancelOnAbort, { once: true }) + } // Calculate input tokens before starting the stream const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) @@ -1407,7 +1417,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } catch (error: unknown) { this.ensureCleanState() - if (error instanceof vscode.CancellationError) { + if (error instanceof vscode.CancellationError || requestCancellation.token.isCancellationRequested) { throw new Error("Zoo Code : Request cancelled by user") } @@ -1431,6 +1441,9 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan console.error("Zoo Code : Unknown stream error:", errorMessage) throw new Error(`Zoo Code : Response stream error: ${errorMessage}`) } + } finally { + // Runs on completion, failure, and early close via the generator's return(). + abortSignal?.removeEventListener("abort", cancelOnAbort) } } From ad49325ef519934677fb1a33962a8f0993c6d549 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 15:22:16 -0700 Subject: [PATCH 25/33] fix(vscode-lm): attach the abort listener inside the request's cleanup scope The abort listener was added before token counting and context-window validation, which run outside the request's try/finally. A failure there left the listener attached to the task's signal. Attach it inside the try, so its finally removes it on every exit path; an abort that already happened, including one during token counting, still cancels before the request is sent. Rewrite the in-flight cancellation test to start the next read before aborting, using a cancellation source that notifies its token like VS Code's, and assert the pending read settles without being released. Cover pre-request validation failure, an abort during token counting, and handler disposal when the provider does not support prompt enhancement. --- .../__tests__/github-copilot.spec.ts | 105 +++++++++++++++++- src/api/providers/vscode-lm.ts | 21 ++-- src/utils/__tests__/enhance-prompt.spec.ts | 10 ++ 3 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 9600887b0c..e51c19e548 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -598,8 +598,45 @@ describe("GitHubCopilotHandler", () => { return { release, token: () => token } } - it("cancels the host request when the task aborts while a chunk is pending", async () => { - const stream = pendingStream() + /** + * A cancellation source whose `cancel()` fires the token's listeners, like VS Code's, + * so a fake host stream can end its pending read the way the real host does. + */ + const firingCancellationSource = () => { + const listeners: Array<() => void> = [] + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn((listener: () => void) => { + listeners.push(listener) + return { dispose: vi.fn() } + }), + } + const source = { + token, + cancel: vi.fn(() => { + if (token.isCancellationRequested) return + token.isCancellationRequested = true + for (const listener of listeners) listener() + }), + dispose: vi.fn(), + } + vi.mocked(vscode.CancellationTokenSource).mockImplementationOnce(function () { + return source as unknown as vscode.CancellationTokenSource + }) + return source + } + + it("ends a stream read that is already pending when the task aborts", async () => { + const source = firingCancellationSource() + mockLanguageModelChat.sendRequest.mockImplementation(async (_m, _o, token: vscode.CancellationToken) => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("first") + // Like the host: the next chunk never arrives, and only cancellation ends the read. + await new Promise((_, reject) => + token.onCancellationRequested(() => reject(new vscode.CancellationError())), + ) + })(), + })) const controller = new AbortController() const handler = new GitHubCopilotHandler(defaultOptions) try { @@ -608,14 +645,70 @@ describe("GitHubCopilotHandler", () => { abortSignal: controller.signal, }) await messages.next() - const [source] = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) - expect(stream.token()).toBe(source.token) + + // Request the next chunk first, so the read is in flight when the abort lands. + const pendingRead = messages.next() + let settled = false + void pendingRead.then( + () => (settled = true), + () => (settled = true), + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(settled).toBe(false) expect(source.cancel).not.toHaveBeenCalled() controller.abort() - expect(source.cancel).toHaveBeenCalledOnce() - stream.release() + await expect(pendingRead).rejects.toThrow("Request cancelled by user") + expect(source.cancel).toHaveBeenCalled() + } finally { + handler.dispose() + } + }) + + it("leaves no abort listener behind when pre-request validation fails", async () => { + // Over the model's input window, so the context-window check throws before sending. + mockLanguageModelChat.countTokens.mockResolvedValue(mockLanguageModelChat.maxInputTokens + 1) + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const handler = new GitHubCopilotHandler(defaultOptions) + try { + await expect( + collectStream( + handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }), + ), + ).rejects.toThrow("exceeds the selected Copilot model's input context window") + } finally { + handler.dispose() + } + + // Every listener added must have been removed, so none outlives the request. + expect(removeSpy.mock.calls.map(([, listener]) => listener)).toEqual( + addSpy.mock.calls.map(([, listener]) => listener), + ) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("cancels before sending when the task aborts during token counting", async () => { + const controller = new AbortController() + mockLanguageModelChat.countTokens.mockImplementation(async () => { + controller.abort() + return 10 + }) + pendingStream() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }) + await messages.next() + const source = vi.mocked(vscode.CancellationTokenSource).mock.results.at(-1)?.value + expect(source.cancel).toHaveBeenCalled() await messages.return(undefined) } finally { handler.dispose() diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index dbdca8faee..bf25b53e1f 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1189,18 +1189,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan ...convertToVsCodeLmMessages(cleanedMessages, this.supportsImageInput(modelInfo)), ] - // Initialize cancellation token for the request, and let the caller's abort - // cancel the host request. Without this, a pending `response.stream` read - // keeps waiting after the task aborts, so the request never unwinds. + // Initialize cancellation token for the request. The caller's abort signal is + // connected to it inside the request `try` below, so its `finally` removes the + // listener on every exit path. const requestCancellation = new vscode.CancellationTokenSource() this.currentRequestCancellation = requestCancellation const abortSignal = metadata?.abortSignal const cancelOnAbort = () => requestCancellation.cancel() - if (abortSignal?.aborted) { - cancelOnAbort() - } else { - abortSignal?.addEventListener("abort", cancelOnAbort, { once: true }) - } // Calculate input tokens before starting the stream const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) @@ -1290,6 +1285,16 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } try { + // Let the caller's abort cancel the host request. Without this, a pending + // `response.stream` read keeps waiting after the task aborts, so the request + // never unwinds. An abort that already happened (including during token + // counting above) cancels before the request is sent. + if (abortSignal?.aborted) { + cancelOnAbort() + } else { + abortSignal?.addEventListener("abort", cancelOnAbort, { once: true }) + } + // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, diff --git a/src/utils/__tests__/enhance-prompt.spec.ts b/src/utils/__tests__/enhance-prompt.spec.ts index d83e5b5e01..83c2bd12d3 100644 --- a/src/utils/__tests__/enhance-prompt.spec.ts +++ b/src/utils/__tests__/enhance-prompt.spec.ts @@ -64,6 +64,16 @@ describe("enhancePrompt", () => { expect(succeeding.dispose).toHaveBeenCalledOnce() }) + it("disposes the handler when the provider does not support prompt enhancement", async () => { + const unsupported = { createMessage: vi.fn(), dispose: vi.fn() } + vi.mocked(buildApiHandler).mockReturnValueOnce(unsupported as unknown as ReturnType) + + await expect(singleCompletionHandler(mockApiConfig, "a")).rejects.toThrow( + "The selected API provider does not support prompt enhancement", + ) + expect(unsupported.dispose).toHaveBeenCalledOnce() + }) + it("enhances prompt using custom enhancement prompt when provided", async () => { const customEnhancePrompt = "You are a custom prompt enhancer" const customEnhancePromptWithTemplate = customEnhancePrompt + "\n\n${userInput}" From 8df74177b0100affff817bed08c7185a77733a2e Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Mon, 12 Oct 2026 15:44:13 -0700 Subject: [PATCH 26/33] feat(copilot): report host tool support, and release pre-send request state Read each Copilot model's tool support from what VS Code reports, as #1971 requires. The resolver reads `capabilities.supportsToolCalling` (consumer side) or `toolCalling` (provider side, a boolean or a tool count) into a new optional ModelInfo.supportsToolCalling. A count only answers "supported?" and is never surfaced as a per-model tool limit. A model the host reports nothing about stays unset, so "not reported" is distinct from "unsupported", and the curated catalog's flag is not borrowed. The Copilot model details show "Supports tools", "Does not support tools", or "Tool support not reported"; other providers are unchanged. When token counting or a pre-send check fails, release the request's cancellation source at once instead of leaving it for the next request. Add a getGitHubCopilotAccount test for when no supported scope set holds a session. --- packages/types/src/model.ts | 3 + .../__tests__/github-copilot.spec.ts | 94 +++++++++++++++++++ src/api/providers/vscode-lm-capabilities.ts | 38 +++++++- src/api/providers/vscode-lm.ts | 14 ++- .../src/components/settings/ModelInfoView.tsx | 15 +++ .../settings/__tests__/ModelInfoView.spec.tsx | 46 +++++++++ webview-ui/src/i18n/locales/ca/settings.json | 3 + webview-ui/src/i18n/locales/de/settings.json | 3 + webview-ui/src/i18n/locales/en/settings.json | 3 + webview-ui/src/i18n/locales/es/settings.json | 3 + webview-ui/src/i18n/locales/fr/settings.json | 3 + webview-ui/src/i18n/locales/hi/settings.json | 3 + webview-ui/src/i18n/locales/id/settings.json | 3 + webview-ui/src/i18n/locales/it/settings.json | 3 + webview-ui/src/i18n/locales/ja/settings.json | 3 + webview-ui/src/i18n/locales/ko/settings.json | 3 + webview-ui/src/i18n/locales/nl/settings.json | 3 + webview-ui/src/i18n/locales/pl/settings.json | 3 + .../src/i18n/locales/pt-BR/settings.json | 3 + webview-ui/src/i18n/locales/ru/settings.json | 3 + webview-ui/src/i18n/locales/tr/settings.json | 3 + webview-ui/src/i18n/locales/vi/settings.json | 3 + .../src/i18n/locales/zh-CN/settings.json | 3 + .../src/i18n/locales/zh-TW/settings.json | 3 + 24 files changed, 259 insertions(+), 5 deletions(-) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 3f4081e78d..9ddc4ea182 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -104,6 +104,9 @@ export const modelInfoSchema = z.object({ maxThinkingTokens: z.number().nullish(), contextWindow: z.number(), supportsImages: z.boolean().optional(), + // Whether the model accepts tool (function) calls, as its host reports it. Only hosts that + // report this set it; unset means "not reported", not "unsupported". + supportsToolCalling: z.boolean().optional(), supportsPromptCache: z.boolean(), // Optional default prompt cache retention policy for providers that support it. // When set to "24h", extended prompt caching will be requested; when omitted diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index e51c19e548..1162aebc6f 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -218,6 +218,14 @@ describe("connectGitHubCopilot", () => { expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) }) + it("reports no account when no supported scope set holds a session", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValue(undefined) + await expect(getGitHubCopilotAccount()).resolves.toBeUndefined() + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(2) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { silent: true }) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["read:user"], { silent: true }) + }) + it("reports no account rather than failing when the lookup throws", async () => { vi.mocked(vscode.authentication.getSession).mockRejectedValue(new Error("auth unavailable")) await expect(getGitHubCopilotAccount()).resolves.toBeUndefined() @@ -437,6 +445,64 @@ describe("Copilot model capabilities", () => { }) }) + describe("tool support resolution", () => { + it.each([true, false])("treats the host-reported tool flag (%s) as authoritative", (reported) => { + const info = getVsCodeLmModelInfo(liveModel({ capabilities: { supportsToolCalling: reported } })) + expect(info.supportsToolCalling).toBe(reported) + }) + + it.each([ + [true, 128], + [true, 1], + [false, 0], + ])("reads the provider-side tool count as supported=%s for %s tools, never as a limit", (expected, count) => { + const info = getVsCodeLmModelInfo(liveModel({ capabilities: { toolCalling: count } })) + expect(info.supportsToolCalling).toBe(expected) + // The count must not be carried into model info as if it were a per-model tool limit. + const withoutCount = getVsCodeLmModelInfo(liveModel({ capabilities: { toolCalling: expected } })) + expect(Object.keys(info).sort()).toEqual(Object.keys(withoutCount).sort()) + }) + + it("accepts the provider-side boolean spelling when the consumer-side one is absent", () => { + expect(getVsCodeLmModelInfo(liveModel({ capabilities: { toolCalling: true } })).supportsToolCalling).toBe( + true, + ) + }) + + it("does not borrow the curated catalog's tool flag for a model the host reports nothing about", () => { + // The curated catalog marks every family `supportsToolCalling: true`; that is not a host report. + expect(getVsCodeLmModelInfo(liveModel({ family: curatedFamily })).supportsToolCalling).toBeUndefined() + }) + + it.each([ + ["no capabilities object", undefined], + ["an empty capabilities object", {}], + ["a capabilities object that says nothing about tools", { supportsImageToText: true }], + ["tools reported as something other than a boolean or count", { supportsToolCalling: "yes" }], + ["a non-finite tool count", { toolCalling: Number.NaN }], + ])("leaves tool support unset for %s", (_label, capabilities) => { + expect(getVsCodeLmModelInfo(liveModel({ capabilities })).supportsToolCalling).toBeUndefined() + }) + + it("keeps tool support independent of vision", () => { + const info = getVsCodeLmModelInfo( + liveModel({ capabilities: { supportsImageToText: false, supportsToolCalling: true } }), + ) + expect(info.supportsImages).toBe(false) + expect(info.supportsToolCalling).toBe(true) + }) + + it("passes the host report through to the models Copilot lists", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValueOnce([ + liveModel({ id: "tools-yes", capabilities: { supportsToolCalling: true } }), + liveModel({ id: "tools-unreported" }), + ] as never) + const models = await getGitHubCopilotModels() + expect(models.find((m) => m.id === "tools-yes")?.modelInfo.supportsToolCalling).toBe(true) + expect(models.find((m) => m.id === "tools-unreported")?.modelInfo.supportsToolCalling).toBeUndefined() + }) + }) + describe("context window", () => { it("uses the live input limit when the catalog has no entry", () => { expect(getVsCodeLmModelInfo(liveModel({ maxInputTokens: 921793 })).contextWindow).toBe(921793) @@ -693,6 +759,34 @@ describe("GitHubCopilotHandler", () => { expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() }) + it("releases the request's cancellation source when a request is rejected before sending", async () => { + // A pre-send check that throws without cleaning up after itself, so only the shared + // request path can release the source. (Copilot's own context-window check also releases it.) + const handler = new GitHubCopilotHandler(defaultOptions) + handler["assertWithinContextWindow"] = () => { + throw new Error("rejected before sending") + } + try { + await expect( + collectStream(handler.createMessage("system", [{ role: "user", content: "hi" }], { taskId: "t" })), + ).rejects.toThrow("rejected before sending") + + // The request's source is the one token counting used; it must be released at failure, + // not left for the next request's or the handler's cleanup. + const countToken = mockLanguageModelChat.countTokens.mock.calls[0][1] + const requestSource = vi + .mocked(vscode.CancellationTokenSource) + .mock.results.map((r) => r.value) + .find((source) => source.token === countToken) + expect(requestSource).toBeDefined() + expect(requestSource.dispose).toHaveBeenCalled() + expect(handler["currentRequestCancellation"]).toBeNull() + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + } finally { + handler.dispose() + } + }) + it("cancels before sending when the task aborts during token counting", async () => { const controller = new AbortController() mockLanguageModelChat.countTokens.mockImplementation(async () => { diff --git a/src/api/providers/vscode-lm-capabilities.ts b/src/api/providers/vscode-lm-capabilities.ts index 4bc795ac38..4272f3e82a 100644 --- a/src/api/providers/vscode-lm-capabilities.ts +++ b/src/api/providers/vscode-lm-capabilities.ts @@ -9,6 +9,12 @@ import { import { canCreateImageParts } from "../transform/vscode-lm-image-part" +/** The model's `capabilities` object, read by name because the stable typings do not declare it. */ +function reportedCapabilities(model: vscode.LanguageModelChat): object | undefined { + const capabilities: unknown = Reflect.get(model, "capabilities") + return typeof capabilities === "object" && capabilities !== null ? capabilities : undefined +} + /** * Vision as the host reports it. * @@ -18,8 +24,8 @@ import { canCreateImageParts } from "../transform/vscode-lm-image-part" * so the caller can tell "unsupported" apart from "unknown". */ export function reportedImageSupport(model: vscode.LanguageModelChat): boolean | undefined { - const capabilities: unknown = Reflect.get(model, "capabilities") - if (typeof capabilities !== "object" || capabilities === null) { + const capabilities = reportedCapabilities(model) + if (!capabilities) { return undefined } for (const key of ["supportsImageToText", "imageInput"]) { @@ -31,6 +37,32 @@ export function reportedImageSupport(model: vscode.LanguageModelChat): boolean | return undefined } +/** + * Tool calling as the host reports it. + * + * VS Code exposes this to consumers as `capabilities.supportsToolCalling` (same proposed surface as + * vision) and to providers as `capabilities.toolCalling`, which may be a boolean or the number of tools + * the model accepts. A number only answers "supported?" here: positive means yes, zero means no. Zoo + * deliberately does not surface it as a per-model tool limit. Anything else yields `undefined`, so + * "not reported" stays distinct from "unsupported". + */ +export function reportedToolSupport(model: vscode.LanguageModelChat): boolean | undefined { + const capabilities = reportedCapabilities(model) + if (!capabilities) { + return undefined + } + for (const key of ["supportsToolCalling", "toolCalling"]) { + const reported: unknown = Reflect.get(capabilities, key) + if (typeof reported === "boolean") { + return reported + } + if (typeof reported === "number" && Number.isFinite(reported)) { + return reported > 0 + } + } + return undefined +} + /** * Derives Zoo's model info from what VS Code reports for a live model. The curated catalog only fills * gaps in everything but vision, and anything neither source states stays unset rather than guessed. @@ -50,6 +82,8 @@ export function getVsCodeLmModelInfo(model: vscode.LanguageModelChat): ModelInfo curatedInputLimit && curatedInputLimit > 0 ? Math.min(liveInputLimit, curatedInputLimit) : liveInputLimit, // A live model's vision is the host's word alone; unreported stays unset rather than borrowing the catalog's. supportsImages: canCreateImageParts() ? reportedImageSupport(model) : false, + // Likewise tool calling: the curated catalog's `supportsToolCalling` is not a host report. + supportsToolCalling: reportedToolSupport(model), supportsPromptCache: false, inputPrice: 0, outputPrice: 0, diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index bf25b53e1f..1dfaa54602 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1197,9 +1197,17 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan const abortSignal = metadata?.abortSignal const cancelOnAbort = () => requestCancellation.cancel() - // Calculate input tokens before starting the stream - const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) - this.assertWithinContextWindow(totalInputTokens, modelInfo) + // Calculate input tokens before starting the stream. A failure here ends the request + // before it is sent, so release its cancellation source now instead of leaving it + // for the next request's ensureCleanState(). The abort listener is not attached yet. + let totalInputTokens: number + try { + totalInputTokens = await this.calculateTotalInputTokens(vsCodeLmMessages) + this.assertWithinContextWindow(totalInputTokens, modelInfo) + } catch (error) { + this.ensureCleanState() + throw error + } // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index c0c6b9fdbf..1ae893eeb2 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -82,6 +82,21 @@ export const ModelInfoView = ({ doesNotSupportLabel={t("settings:modelInfo.noImages")} /> ), + // Tool support is shown only where the host reports it per model. It is a yes/no answer: + // VS Code does not report how many tools a model accepts, so no limit is implied. + apiProvider === providerIdentifiers.githubCopilot && + (modelInfo?.supportsToolCalling === undefined ? ( +
+
+ ) : ( + + )), apiProvider === providerIdentifiers.githubCopilot ? (
({ "settings:modelInfo.imageSupportUnknown": "Image support not reported", "settings:modelInfo.supportsImages": "Supports images", "settings:modelInfo.noImages": "Does not support images", + "settings:modelInfo.toolSupportUnknown": "Tool support not reported", + "settings:modelInfo.supportsTools": "Supports tools", + "settings:modelInfo.noTools": "Does not support tools", "settings:modelInfo.noPromptCache": "Does not support prompt caching", "settings:modelInfo.promptCacheManagedByCopilot": "Prompt caching managed by GitHub Copilot", "settings:serviceTier.pricingTableTitle": "Service tier pricing", @@ -103,6 +106,49 @@ describe("ModelInfoView service tier pricing", () => { expect(screen.queryByText("Does not support prompt caching")).not.toBeInTheDocument() }) + it.each([ + [true, "Supports tools"], + [false, "Does not support tools"], + ])("shows host-reported Copilot tool support (%s) as reported", (supportsToolCalling, label) => { + render( + , + ) + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.queryByText("Tool support not reported")).not.toBeInTheDocument() + }) + + it("shows unreported Copilot tool support without claiming either way", () => { + render( + , + ) + expect(screen.getByText("Tool support not reported")).toBeInTheDocument() + expect(screen.queryByText("Supports tools")).not.toBeInTheDocument() + expect(screen.queryByText("Does not support tools")).not.toBeInTheDocument() + }) + + it("does not add a tool-support row for other providers", () => { + render( + , + ) + expect(screen.queryByText("Supports tools")).not.toBeInTheDocument() + expect(screen.queryByText("Tool support not reported")).not.toBeInTheDocument() + }) + it("keeps unsupported caching labels for providers whose models do not support caching", () => { render( Date: Mon, 12 Oct 2026 17:39:41 -0700 Subject: [PATCH 27/33] fix(copilot): cancel token counting on abort, and show tool support only as the host reports it Connect the task's abort signal before token counting. It was attached only after counting, so an abort while the host was still counting never reached the count's cancellation token and createMessage could stay pending. The request's cleanup scope now covers counting, the context-window check, and the request. A cancelled count reads as zero, so stop with a cancellation after counting instead of sending a request the caller abandoned. The caller's abort is tracked on its own, because cleanup also cancels the token and an ordinary pre-send failure must not be reported as a cancellation. Release only the failing request's cancellation source. A request that fails after a newer one has started no longer cancels the newer one. The webview took a Copilot model's tool support from the curated catalog, which marks every family as supporting tools, so the settings showed "Supports tools" for a model the host said nothing about. Use the host's report alone, as for vision. Update the signed-in visual baseline, regenerated in the pinned Playwright image CI uses, which now shows "Tool support not reported". Compare the whole ModelInfo in the tool-count test rather than its keys. --- .../__tests__/github-copilot.spec.ts | 167 ++++++++++++++++-- src/api/providers/github-copilot.ts | 3 +- src/api/providers/vscode-lm.ts | 59 ++++--- .../GitHubCopilotSettings.visual.tsx | 4 +- .../github-copilot-signed-in-dark.png | Bin 24322 -> 25859 bytes .../hooks/__tests__/useSelectedModel.spec.ts | 23 +++ .../components/ui/hooks/useSelectedModel.ts | 4 +- 7 files changed, 219 insertions(+), 41 deletions(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 1162aebc6f..1bfb601baa 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -458,9 +458,10 @@ describe("Copilot model capabilities", () => { ])("reads the provider-side tool count as supported=%s for %s tools, never as a limit", (expected, count) => { const info = getVsCodeLmModelInfo(liveModel({ capabilities: { toolCalling: count } })) expect(info.supportsToolCalling).toBe(expected) - // The count must not be carried into model info as if it were a per-model tool limit. + // The count must change nothing else: the result equals what the plain boolean + // report produces, so it is never carried into model info as a per-model tool limit. const withoutCount = getVsCodeLmModelInfo(liveModel({ capabilities: { toolCalling: expected } })) - expect(Object.keys(info).sort()).toEqual(Object.keys(withoutCount).sort()) + expect(info).toStrictEqual(withoutCount) }) it("accepts the provider-side boolean spelling when the consumer-side one is absent", () => { @@ -759,6 +760,75 @@ describe("GitHubCopilotHandler", () => { expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() }) + const requireValue = (value: T | null | undefined): T => { + if (value == null) throw new Error("Expected a value") + return value + } + + it("releases only the failing request's source when a newer request has already started", async () => { + // Two requests on one handler. The first is held in token counting; the second starts and + // replaces the handler's current source; then the first fails its pre-send check. + const handler = new GitHubCopilotHandler(defaultOptions) + await handler.initializeClient() + let releaseFirstCount!: () => void + const firstCountHeld = new Promise((resolve) => (releaseFirstCount = resolve)) + let firstCounting!: () => void + const firstCountStarted = new Promise((resolve) => (firstCounting = resolve)) + let secondCounting!: () => void + const secondCountStarted = new Promise((resolve) => (secondCounting = resolve)) + // The first request's counts are held and come back large, so only it fails the check + // below; any later count belongs to the second request and comes back small. + const FIRST_REQUEST_TOKENS = 1_000 + let firstToken: unknown + mockLanguageModelChat.countTokens.mockImplementation(async (_text: unknown, token: unknown) => { + firstToken ??= token + if (token === firstToken) { + firstCounting() + await firstCountHeld + return FIRST_REQUEST_TOKENS + } + secondCounting() + return 10 + }) + handler["assertWithinContextWindow"] = (inputTokens: number) => { + if (inputTokens >= FIRST_REQUEST_TOKENS) { + throw new Error("first rejected before sending") + } + } + const stream = pendingStream() + try { + vi.mocked(vscode.CancellationTokenSource).mockClear() + const first = collectStream( + handler.createMessage("system", [{ role: "user", content: "a" }], { taskId: "t" }), + ) + await firstCountStarted + + const second = handler.createMessage("system", [{ role: "user", content: "b" }], { taskId: "t" }) + const secondFirstChunk = second.next() + await secondCountStarted + const sources = vi.mocked(vscode.CancellationTokenSource).mock.results.map((r) => r.value) + const firstSource = sources.find((source) => source.token === firstToken) + const secondSource = requireValue(handler["currentRequestCancellation"]) + expect(firstSource).toBeDefined() + expect(secondSource).not.toBe(firstSource) + + releaseFirstCount() + await expect(first).rejects.toThrow("first rejected before sending") + + // The first request released its own source and left the newer one running. + expect(firstSource.dispose).toHaveBeenCalled() + expect(secondSource.cancel).not.toHaveBeenCalled() + expect(handler["currentRequestCancellation"]).toBe(secondSource) + + await secondFirstChunk + expect(stream.token()).toBe(secondSource.token) + stream.release() + await second.return(undefined) + } finally { + handler.dispose() + } + }) + it("releases the request's cancellation source when a request is rejected before sending", async () => { // A pre-send check that throws without cleaning up after itself, so only the shared // request path can release the source. (Copilot's own context-window check also releases it.) @@ -787,7 +857,46 @@ describe("GitHubCopilotHandler", () => { } }) - it("cancels before sending when the task aborts during token counting", async () => { + it("ends a token count that is still pending when the task aborts, and never sends", async () => { + const source = firingCancellationSource() + let countStarted!: () => void + const counting = new Promise((resolve) => (countStarted = resolve)) + // Like the host counter for this request: it never answers, and only cancellation of + // its token ends it. Counts made with any other token (client setup) answer normally. + mockLanguageModelChat.countTokens.mockImplementation((_text: unknown, token: unknown) => { + if (token !== source.token) return Promise.resolve(10) + return new Promise((_, reject) => { + countStarted() + source.token.onCancellationRequested(() => reject(new vscode.CancellationError())) + }) + }) + const controller = new AbortController() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + const request = collectStream( + handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }), + ) + await counting + // The pending count holds the request's own token, so the abort can reach it. + expect(mockLanguageModelChat.countTokens.mock.calls.some(([, token]) => token === source.token)).toBe( + true, + ) + expect(source.cancel).not.toHaveBeenCalled() + + controller.abort() + + await expect(request).rejects.toThrow("Request cancelled by user") + expect(source.cancel).toHaveBeenCalled() + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + } finally { + handler.dispose() + } + }) + + it("does not send when the task aborts while tokens are being counted", async () => { const controller = new AbortController() mockLanguageModelChat.countTokens.mockImplementation(async () => { controller.abort() @@ -796,33 +905,57 @@ describe("GitHubCopilotHandler", () => { pendingStream() const handler = new GitHubCopilotHandler(defaultOptions) try { - const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { - taskId: "t", - abortSignal: controller.signal, - }) - await messages.next() + await expect( + collectStream( + handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }), + ), + ).rejects.toThrow("Request cancelled by user") const source = vi.mocked(vscode.CancellationTokenSource).mock.results.at(-1)?.value expect(source.cancel).toHaveBeenCalled() - await messages.return(undefined) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() } finally { handler.dispose() } }) - it("cancels before sending when the task already aborted", async () => { + it("does not count or send when the task already aborted", async () => { pendingStream() + const source = firingCancellationSource() const controller = new AbortController() controller.abort() const handler = new GitHubCopilotHandler(defaultOptions) try { - const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { - taskId: "t", - abortSignal: controller.signal, - }) - await messages.next() - const source = vi.mocked(vscode.CancellationTokenSource).mock.results.at(-1)?.value + await expect( + collectStream( + handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }), + ), + ).rejects.toThrow("Request cancelled by user") expect(source.cancel).toHaveBeenCalled() - await messages.return(undefined) + // Any count that did start saw an already-cancelled token. + for (const [, token] of mockLanguageModelChat.countTokens.mock.calls) { + expect((token as vscode.CancellationToken).isCancellationRequested).toBe(true) + } + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + } finally { + handler.dispose() + } + }) + + it("reports an ordinary pre-send failure as itself, not as a cancellation", async () => { + const handler = new GitHubCopilotHandler(defaultOptions) + handler["assertWithinContextWindow"] = () => { + throw new Error("rejected before sending") + } + try { + await expect( + collectStream(handler.createMessage("system", [{ role: "user", content: "hi" }], { taskId: "t" })), + ).rejects.toThrow("rejected before sending") } finally { handler.dispose() } diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts index bcf515c9cd..d5440cd25c 100644 --- a/src/api/providers/github-copilot.ts +++ b/src/api/providers/github-copilot.ts @@ -66,7 +66,8 @@ export class GitHubCopilotHandler extends VsCodeLmHandler { protected override assertWithinContextWindow(inputTokens: number, info: ModelInfo): void { if (inputTokens > info.contextWindow) { - this.ensureCleanState() + // No cleanup here: the request that called this releases its own cancellation + // source when the error reaches it, without touching any newer request. throw Object.assign( new Error( "The request exceeds the selected Copilot model's input context window. Condense the conversation or start a new task.", diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 1dfaa54602..5dee3f2acf 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1074,6 +1074,18 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } + /** + * Releases one request's cancellation source. Unlike ensureCleanState(), it leaves a newer + * request alone: a request that fails after another has started must not cancel that one. + */ + protected releaseRequestCancellation(source: vscode.CancellationTokenSource): void { + source.cancel() + source.dispose() + if (this.currentRequestCancellation === source) { + this.currentRequestCancellation = null + } + } + protected async getClient(): Promise { if (!this.client) { console.debug("Zoo Code : Getting client with options:", { @@ -1190,23 +1202,17 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan ] // Initialize cancellation token for the request. The caller's abort signal is - // connected to it inside the request `try` below, so its `finally` removes the - // listener on every exit path. + // connected to it at the start of the request `try` below, so its `finally` + // removes the listener on every exit path. const requestCancellation = new vscode.CancellationTokenSource() this.currentRequestCancellation = requestCancellation const abortSignal = metadata?.abortSignal - const cancelOnAbort = () => requestCancellation.cancel() - - // Calculate input tokens before starting the stream. A failure here ends the request - // before it is sent, so release its cancellation source now instead of leaving it - // for the next request's ensureCleanState(). The abort listener is not attached yet. - let totalInputTokens: number - try { - totalInputTokens = await this.calculateTotalInputTokens(vsCodeLmMessages) - this.assertWithinContextWindow(totalInputTokens, modelInfo) - } catch (error) { - this.ensureCleanState() - throw error + // Recorded separately from the token: cleanup also cancels the token, so the + // token alone cannot tell a caller's abort apart from an ordinary failure. + let abortedByCaller = false + const cancelOnAbort = () => { + abortedByCaller = true + requestCancellation.cancel() } // Accumulate the text and count at the end of the stream to reduce token counting overhead. @@ -1293,16 +1299,25 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } try { - // Let the caller's abort cancel the host request. Without this, a pending - // `response.stream` read keeps waiting after the task aborts, so the request - // never unwinds. An abort that already happened (including during token - // counting above) cancels before the request is sent. + // Let the caller's abort cancel the host work before any of it starts. Token + // counting and the request both take this token, so an abort during either + // ends it instead of leaving it pending; an abort that already happened + // cancels at once. if (abortSignal?.aborted) { cancelOnAbort() } else { abortSignal?.addEventListener("abort", cancelOnAbort, { once: true }) } + // Calculate input tokens before starting the stream. + const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) + // Token counting reports a cancelled count as zero, so stop here rather than + // send a request the caller has already abandoned. + if (abortedByCaller) { + throw new vscode.CancellationError() + } + this.assertWithinContextWindow(totalInputTokens, modelInfo) + // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, @@ -1311,7 +1326,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan const response: vscode.LanguageModelChatResponse = await client.sendRequest( vsCodeLmMessages, requestOptions, - this.currentRequestCancellation.token, + requestCancellation.token, ) // Consume the stream and handle both text and tool call chunks @@ -1428,9 +1443,11 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan outputTokens: totalOutputTokens, } } catch (error: unknown) { - this.ensureCleanState() + // Releases this request's cancellation source, never a newer request's. That cancels + // its token too, which is why a caller's abort is read from `abortedByCaller`. + this.releaseRequestCancellation(requestCancellation) - if (error instanceof vscode.CancellationError || requestCancellation.token.isCancellationRequested) { + if (error instanceof vscode.CancellationError || abortedByCaller) { throw new Error("Zoo Code : Request cancelled by user") } diff --git a/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx index 248ff0818a..fa0dc94d0c 100644 --- a/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx +++ b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx @@ -6,7 +6,8 @@ import { mountedStory } from "../../../../playwright/mounted-story" const send = (page: Page, data: object) => page.evaluate((message) => window.dispatchEvent(new MessageEvent("message", { data: message })), data) -// The host reports context but says nothing about vision, which the UI must show as unreported. +// The host reports context but says nothing about vision or tool support, which the UI must +// show as unreported. const model = { id: "gpt-5.5", vendor: "copilot", family: "gpt-5.5", version: "1", name: "GPT-5.5" } const reportedModel = { ...model, modelInfo: { contextWindow: 268_426, supportsPromptCache: false } } @@ -36,6 +37,7 @@ test("renders a signed-in account with host-reported capabilities in the VS Code await expect(component.getByRole("status")).toContainText("octocat") await expect(component.getByText("Image support not reported")).toBeVisible() + await expect(component.getByText("Tool support not reported")).toBeVisible() await settle(component) await expect(component).toHaveScreenshot("github-copilot-signed-in-dark.png") diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png index 4c1aaed1fa58db5b6dc0e084f7ad9a3b5335fcba..3cc7172014d780e3d9483b6b25cc089f98bc79e0 100644 GIT binary patch literal 25859 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 CaAI=+ literal 24322 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 { ).toBeUndefined() }) + it("keeps tool support unknown until the host reports it, even though the catalog marks every family", () => { + const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsToolCalling) ?? [] + expect(curatedFamily).toBeDefined() + + expect( + selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family: curatedFamily }).info + ?.supportsToolCalling, + ).toBeUndefined() + }) + + it.each([true, false])("shows the host-reported tool support (%s) for the selected model", (reported) => { + reportedModels({ + id: "dynamic-tools", + family: "dynamic-tools", + modelInfo: { contextWindow: 128000, supportsToolCalling: reported, supportsPromptCache: false }, + }) + + expect( + selectCopilot({ vendor: "copilot", id: "dynamic-tools", family: "dynamic-tools" }).info + ?.supportsToolCalling, + ).toBe(reported) + }) + it("keeps missing Copilot image capability unknown instead of setting it to false", () => { expect( selectCopilot({ vendor: "copilot", id: "unknown-model", family: "unknown-model" }).info?.supportsImages, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index c69ea1cad3..b8ae7253e1 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -459,8 +459,10 @@ function getSelectedModel({ // The extension enforces the curated input limit, so the UI must not show the larger window. ...(knownModel && { contextWindow: knownModel.maxInputTokens }), ...reported?.modelInfo, - // Vision is the host's word alone, as the extension enforces; the catalog's flag is never shown. + // Vision and tool support are the host's word alone, as the extension reads them; the + // catalog marks every family as supporting both, which is not a host report. supportsImages: reported?.modelInfo?.supportsImages, + supportsToolCalling: reported?.modelInfo?.supportsToolCalling, supportsPromptCache: false, }, } From 5c5246b5b90bf4e47ffb921b2cf009c7bd1f5bd1 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Tue, 13 Oct 2026 20:27:07 -0700 Subject: [PATCH 28/33] docs(readme): summarize the GitHub Copilot provider in What's New; drop an unused import --- README.md | 41 +---------------------- src/core/webview/webviewMessageHandler.ts | 1 - 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/README.md b/README.md index c3cd6bb642..07c8e67de3 100644 --- a/README.md +++ b/README.md @@ -46,48 +46,9 @@ Zoo Code builds on the foundation created by Roo Code and continues to expand it - **More dependable terminal and editing workflows** — fixes for premature terminal completion, task-state races, context management, diff editing, and provider-specific tool use. - **More control over your workspace** — rules management, per-mode MCP restrictions, multi-root path controls, model reasoning options, and completion change review actions. -## GitHub Copilot - -Select **GitHub Copilot** in Zoo Code's provider settings and click **Sign in to GitHub**. GitHub -Copilot Chat must be installed and enabled. Zoo Code shows the signed-in account and refreshes the -model list as Copilot registers models. Select a model and save the profile. VS Code may ask you to -allow Zoo Code to use the model on the first request. - -Signing in proves a GitHub identity, not a Copilot subscription. An account without Copilot access -signs in successfully and simply sees no models. - -- **Reconnect GitHub** requests a fresh authorization instead of reusing the existing session. -- **Manage GitHub account** opens VS Code's own account menu. VS Code offers extensions no way to - sign out on a user's behalf, so signing out happens there. It affects Copilot and every other - extension using the account. - -VS Code manages the GitHub session; Zoo Code never displays or stores the access token. The account -label follows VS Code, so signing out in the Accounts menu clears it. - -### Capabilities - -Capabilities come from what VS Code reports for each Copilot model, so new models work without code -changes, and nothing about a model is saved into your settings where it could go stale. - -- **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one - is known. Before the host reports, or if it reports no usable limit, the limit shown matches what - Zoo Code enforces. -- **Images:** read only from the capability the host reports for each model. Until it reports, or if it - reports nothing, the model shows **Image support not reported** instead of being assumed supported - or unsupported, and Zoo Code will not send images to it. Images are sent as image data, not text - placeholders, and need a VS Code version that supports image parts; Zoo Code's minimum supported - VS Code version stays 1.100. -- **Tools:** VS Code tells extensions only whether a model supports tools, not how many it accepts, - so Zoo Code applies the same advisory it shows for any provider when many MCP tools are enabled. - GitHub plan limits, premium-request allowances, organization policies, and backend limits still apply. -- **Prompt caching:** Copilot owns any backend caching. The public VS Code Language Model API exposes - no cache-control directives or cache-hit usage, so Zoo Code neither enables caching nor reports - savings; the provider-managed label states this and does not promise a cache hit. - -The existing **VS Code LM API** provider remains available for models from other extensions. - ## What's New in v3.88.0 +- 🐙 **GitHub Copilot provider:** Sign in to GitHub and use your Copilot models directly in Zoo Code. - ✨ **Provider and model support:** Use Grok Build and improved custom-model support. This release also fixes Bedrock model limits and sends the Codex verbosity setting. - 🧭 **Task reliability:** This release fixes cancellation handling, tool-turn continuation, and task-message saving. - ⚡ **Faster editing and chat:** File writes no longer use artificial delays by default. Chat messages carry less file data, and code actions reach the last active chat. diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 94f952e35e..6626425b8a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,7 +23,6 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, - githubCopilotLanguageModel, retiredProviderIdentifiers, LmStudioModelsMessageType, OllamaModelsMessageType, From 72b091c84ce3ef3a3ab55691c6a6cdb655678b7d Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Tue, 13 Oct 2026 22:14:08 -0700 Subject: [PATCH 29/33] fix(copilot): drop stale passive model refreshes and release temporary handlers Two overlapping passive model refreshes for one view captured the same sign-in counter, so an older refresh finishing last could overwrite the newer model list or error. Give passive refreshes their own per-view sequence and check it before posting either result. The system prompt preview and provider profile export each build an API handler only to read model metadata, and never released it. GitHub Copilot handlers register a host configuration listener, so these accumulated. Dispose each temporary handler in a finally block, on success, timeout, and error. disposeApiHandler moves to its own module so callers keep cleaning up where tests replace the api module. Add Task tests that run the real request lifecycle: a stream the consumer abandons is closed so its replaced handler is released, a retry keeps the handler alive until the retry's stream settles, and the background usage drain does too. --- src/api/dispose-api-handler.ts | 12 ++ src/api/index.ts | 10 +- src/core/config/ProviderSettingsManager.ts | 14 +- .../__tests__/ProviderSettingsManager.spec.ts | 74 ++++++++++- src/core/task/__tests__/Task.spec.ts | 123 ++++++++++++++++++ .../__tests__/generateSystemPrompt.spec.ts | 73 ++++++++++- .../__tests__/webviewMessageHandler.spec.ts | 67 ++++++++++ src/core/webview/generateSystemPrompt.ts | 12 +- src/core/webview/webviewMessageHandler.ts | 7 +- .../language-models/copilotViewState.ts | 4 +- 10 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 src/api/dispose-api-handler.ts diff --git a/src/api/dispose-api-handler.ts b/src/api/dispose-api-handler.ts new file mode 100644 index 0000000000..c02ae0087c --- /dev/null +++ b/src/api/dispose-api-handler.ts @@ -0,0 +1,12 @@ +/** + * Releases host resources an API handler owns, such as the configuration listener VS Code LM and + * GitHub Copilot handlers register. Handlers without a `dispose()` method hold nothing to release. + * + * This lives apart from `api/index.ts` so callers keep cleaning up even where tests replace that + * module with a partial fake. It is typed structurally so it does not depend on that module either. + */ +export function disposeApiHandler(handler: object | null | undefined): void { + if (handler && "dispose" in handler && typeof handler.dispose === "function") { + handler.dispose() + } +} diff --git a/src/api/index.ts b/src/api/index.ts index 9292281d58..d823bb6c95 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -185,15 +185,7 @@ export interface ApiHandler { getModelCacheScope(): ModelCacheScope } -/** - * Releases host resources a handler owns (for example the VS Code LM / GitHub Copilot configuration - * listener). Handlers without a `dispose()` method hold nothing that needs releasing. - */ -export function disposeApiHandler(handler: ApiHandler | undefined): void { - if (handler && "dispose" in handler && typeof handler.dispose === "function") { - handler.dispose() - } -} +export { disposeApiHandler } from "./dispose-api-handler" export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index e0f272a578..0e7d829d58 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -19,6 +19,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" import { buildApiHandler } from "../../api" +import { disposeApiHandler } from "../../api/dispose-api-handler" import { downgradeLegacyRooConfig } from "./routerRemoval" // Type-safe model migrations mapping @@ -594,9 +595,12 @@ export class ProviderSettingsManager { continue } - // Try to build an API handler to get model information + // Try to build an API handler to get model information. It is used only for + // that, and some handlers (VS Code LM, GitHub Copilot) register host listeners, + // so release it whether filtering succeeds or fails. + let apiHandler: ReturnType | undefined try { - const apiHandler = buildApiHandler(configs[name]) + apiHandler = buildApiHandler(configs[name]) const modelInfo = apiHandler.getModel().info // Check if the model supports reasoning budgets @@ -619,6 +623,12 @@ export class ProviderSettingsManager { // If we can't build the API handler or get model info, skip filtering // to avoid accidental data loss from incomplete configurations console.warn(`Skipping token field filtering for config '${name}': ${error}`) + } finally { + try { + disposeApiHandler(apiHandler) + } catch (error) { + console.warn(`Could not release the API handler for config '${name}': ${error}`) + } } } return profiles diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 3e5a0cd22c..a1fc9ac7de 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -19,6 +19,12 @@ import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsMa // 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). +// Every handler export() builds, so tests can check each temporary one is released. A test can +// set `failGetModel` to make reading model info throw. +const builtHandlers = vi.hoisted(() => + Object.assign([] as Array<{ apiProvider?: string; dispose: ReturnType }>, { failGetModel: false }), +) + vi.mock("../../../api", async () => { const types = await vi.importActual("@roo-code/types") const zaiModels = { ...types.internationalZAiModels, ...types.mainlandZAiModels } as Record @@ -35,9 +41,19 @@ vi.mock("../../../api", async () => { } } return { - buildApiHandler: (config: { apiProvider?: string; apiModelId?: string }) => ({ - getModel: () => ({ id: config?.apiModelId ?? "", info: modelInfoFor(config) }), - }), + buildApiHandler: (config: { apiProvider?: string; apiModelId?: string }) => { + const handler = { + apiProvider: config?.apiProvider, + getModel: () => { + if (builtHandlers.failGetModel) throw new Error("no model info") + return { id: config?.apiModelId ?? "", info: modelInfoFor(config) } + }, + // Handlers such as GitHub Copilot hold a host listener export() must release. + dispose: vi.fn(), + } + builtHandlers.push(handler) + return handler + }, } }) @@ -1192,6 +1208,58 @@ describe("ProviderSettingsManager", () => { }) describe("Export", () => { + it("releases every temporary API handler it builds to read model info", async () => { + builtHandlers.length = 0 + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "copilot", + apiConfigs: { + copilot: { id: "copilot-id", apiProvider: providerIdentifiers.githubCopilot }, + glm: { id: "glm-id", apiProvider: providerIdentifiers.zai, apiModelId: "glm-5.1" }, + }, + } satisfies ProviderProfiles), + ) + + await providerSettingsManager.export() + + expect(builtHandlers.map((handler) => handler.apiProvider).sort()).toEqual( + [providerIdentifiers.githubCopilot, providerIdentifiers.zai].sort(), + ) + for (const handler of builtHandlers) { + expect(handler.dispose).toHaveBeenCalledOnce() + } + }) + + it("still exports, and still releases the handler, when reading model info fails", async () => { + builtHandlers.length = 0 + builtHandlers.failGetModel = true + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "copilot", + apiConfigs: { + copilot: { + id: "copilot-id", + apiProvider: providerIdentifiers.githubCopilot, + modelMaxTokens: 1, + }, + }, + } satisfies ProviderProfiles), + ) + + const exported = await providerSettingsManager.export() + + // Filtering is skipped rather than guessing, and the handler is still released. + expect(exported.apiConfigs.copilot.modelMaxTokens).toBe(1) + expect(builtHandlers).toHaveLength(1) + expect(builtHandlers[0].dispose).toHaveBeenCalledOnce() + } finally { + builtHandlers.failGetModel = false + warnSpy.mockRestore() + } + }) + it("should preserve retired provider profiles with full fields", async () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "retired", diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 49fc1b88bc..3066613263 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1960,6 +1960,129 @@ describe("Cline", () => { consoleError.mockRestore() }) + describe("through the real request lease", () => { + /** Lets the first `count` requests run the real lifecycle wrapper, then stops the loop. */ + const realRequestsThenStop = (task: Task, count = 1) => { + const real = task.attemptApiRequest.bind(task) + let calls = 0 + return vi.spyOn(task, "attemptApiRequest").mockImplementation((...args) => { + if (++calls <= count) return real(...args) + throw new Error("stop after the request under test") + }) + } + + it("closes a stream whose read failed, releasing the replaced handler (stream.return())", async () => { + const task = await createTaskWithManualRetries() + const oldDispose = disposableApi(task) + let releaseAfterAbort!: () => void + const afterAbort = new Promise((resolve) => (releaseAfterAbort = resolve)) + let firstChunkSent!: () => void + const firstChunk = new Promise((resolve) => (firstChunkSent = resolve)) + vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( + async function* () { + task.currentRequestAbortController = new AbortController() + yield { type: "text", text: "a" } + firstChunkSent() + // A host that ignores cancellation and keeps producing after the + // consumer gave up: only `return()` can end this generator. + await afterAbort + yield { type: "text", text: "late" } + yield { type: "text", text: "later" } + }, + ) + realRequestsThenStop(task) + vi.spyOn(console, "error").mockImplementation(() => {}) + + const loop = task.recursivelyMakeClineRequests([{ type: "text", text: "go" }]) + await firstChunk + task.updateApiConfiguration({ ...mockApiConfig }) + expect(oldDispose).not.toHaveBeenCalled() + + // The read in flight is abandoned by the consumer (not failed by the stream). + task.currentRequestAbortController?.abort() + releaseAfterAbort() + await loop + + await vi.waitFor(() => expect(oldDispose).toHaveBeenCalledOnce()) + expect(getTaskTestAccess(task).retiredApis.size).toBe(0) + }) + + it("keeps a replaced handler alive through a retry until the retry's stream settles", async () => { + const task = newTask() + const oldDispose = disposableApi(task) + let startRetry!: () => void + const retryGate = new Promise((resolve) => (startRetry = resolve)) + let finishRetry!: () => void + const retryDone = new Promise((resolve) => (finishRetry = resolve)) + let attempt = 0 + vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( + async function* () { + if (++attempt === 1) { + // The first attempt fails and retries through the public method, + // as the real first-chunk and context-window paths do. + await retryGate + yield* task.attemptApiRequest(1) + return + } + yield { type: "text", text: "retried" } + await retryDone + }, + ) + + const request = task.attemptApiRequest() + const first = request.next() + await Promise.resolve() + task.updateApiConfiguration({ ...mockApiConfig }) + expect(oldDispose).not.toHaveBeenCalled() + + startRetry() + await first + expect(attempt).toBe(2) + expect(oldDispose).not.toHaveBeenCalled() + + finishRetry() + for await (const _ of request) { + // drain + } + expect(oldDispose).toHaveBeenCalledOnce() + }) + + it("keeps a replaced handler alive until the background usage drain finishes", async () => { + const task = await createTaskWithManualRetries() + const oldDispose = disposableApi(task) + let finishDrain!: () => void + const drainGate = new Promise((resolve) => (finishDrain = resolve)) + let drainStarted!: () => void + const draining = new Promise((resolve) => (drainStarted = resolve)) + vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( + async function* () { + yield { type: "text", text: "a" } + // The main loop stops reading here and hands the rest of the stream to + // the background usage drain. + task.didRejectTool = true + yield { type: "text", text: "b" } + drainStarted() + await drainGate + yield { type: "usage", inputTokens: 1, outputTokens: 1 } + }, + ) + realRequestsThenStop(task) + vi.spyOn(console, "error").mockImplementation(() => {}) + + const loop = task.recursivelyMakeClineRequests([{ type: "text", text: "go" }]) + // Reached only when the background drain reads past the main loop's last chunk. + await draining + task.updateApiConfiguration({ ...mockApiConfig }) + // Give any premature release a chance to run before checking it did not. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(oldDispose).not.toHaveBeenCalled() + + finishDrain() + await loop + await vi.waitFor(() => expect(oldDispose).toHaveBeenCalledOnce()) + }) + }) + it("disposes the current handler exactly once even when dispose is called twice", async () => { const task = new Task({ provider: mockProvider, diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index a71598ae57..da9782fddb 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -129,19 +129,27 @@ const modelMock = vi.hoisted(() => { const ensureModelFetched = vi.fn(async () => { state.fetched = true }) - return { state, ensureModelFetched } + // Every handler the preview builds, so tests can check each one is released. + const handlers: Array<{ dispose: ReturnType }> = [] + return { state, ensureModelFetched, handlers } }) // Note: the module under test imports `../../api` from src/core/webview, which // resolves to src/api — from this spec's directory (one level deeper) that is // `../../../api`. vi.mock("../../../api", () => ({ - buildApiHandler: () => ({ - ensureModelFetched: modelMock.ensureModelFetched, - // The handler only knows its full metadata (incl. excludedTools) after - // ensureModelFetched() resolves, mirroring router providers. - getModel: () => ({ id: "m", info: modelMock.state.fetched ? fullModelInfo : fallbackModelInfo }), - }), + buildApiHandler: () => { + const handler = { + ensureModelFetched: modelMock.ensureModelFetched, + // The handler only knows its full metadata (incl. excludedTools) after + // ensureModelFetched() resolves, mirroring router providers. + getModel: () => ({ id: "m", info: modelMock.state.fetched ? fullModelInfo : fallbackModelInfo }), + // Handlers such as GitHub Copilot hold a host listener the preview must release. + dispose: vi.fn(), + } + modelMock.handlers.push(handler) + return handler + }, })) // Minimal mock ExtensionContext, mirroring the pattern in system-prompt.spec.ts. @@ -327,6 +335,57 @@ describe("generateSystemPrompt preview parity", () => { ) }) + describe("releasing the preview's API handler", () => { + beforeEach(() => { + modelMock.handlers.length = 0 + }) + + it("disposes the handler once the preview is built", async () => { + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(modelMock.handlers).toHaveLength(1) + expect(modelMock.handlers[0].dispose).toHaveBeenCalledOnce() + }) + + it("disposes the handler when the metadata fetch fails", async () => { + modelMock.ensureModelFetched.mockRejectedValueOnce(new Error("network down")) + + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(modelMock.handlers[0].dispose).toHaveBeenCalledOnce() + }) + + it("disposes the handler when the metadata fetch times out", async () => { + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockImplementationOnce(() => new Promise(() => {})) + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + await vi.advanceTimersByTimeAsync(5_000) + await previewPromise + } finally { + vi.useRealTimers() + } + + expect(modelMock.handlers[0].dispose).toHaveBeenCalledOnce() + }) + + it("still builds the preview when disposing the handler throws", async () => { + modelMock.ensureModelFetched.mockImplementationOnce(async () => { + modelMock.handlers[0].dispose.mockImplementation(() => { + throw new Error("dispose failed") + }) + }) + + await expect(generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" })).resolves.toEqual( + expect.any(String), + ) + expect(errorSpy).toHaveBeenCalledWith( + "Error disposing the system prompt preview's API handler:", + expect.any(Error), + ) + }) + }) + it("degrades to fallback metadata when ensureModelFetched hangs past the preview timeout", async () => { // A hung metadata endpoint (some fetchers issue unbounded GETs) must not // block the user-triggered preview: after PREVIEW_MODEL_FETCH_TIMEOUT_MS diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 99cd6341b8..ad87d2761c 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -472,6 +472,73 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) }) + it("discards an older refresh that finishes after a newer one, even with no sign-in", async () => { + const finishers: Array<(models: Array<{ id: string }>) => void> = [] + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( + () => new Promise((resolve) => finishers.push(resolve as (models: Array<{ id: string }>) => void)), + ) + + const older = passiveRequest() + const newer = passiveRequest() + await vi.waitFor(() => expect(finishers).toHaveLength(2)) + + finishers[1]([{ id: "newer" }]) + await newer + finishers[0]([{ id: "older" }]) + await older + + const posted = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls.map(([message]) => message) + expect(posted).toEqual([ + { type: "githubCopilotModels", githubCopilotAccount: "Test User", vsCodeLmModels: [{ id: "newer" }] }, + ]) + }) + + it("discards an older refresh's error that arrives after a newer refresh succeeded", async () => { + const settlers: Array<{ resolve: (models: []) => void; reject: (error: Error) => void }> = [] + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( + () => new Promise((resolve, reject) => settlers.push({ resolve, reject })), + ) + + const older = passiveRequest() + const newer = passiveRequest() + await vi.waitFor(() => expect(settlers).toHaveLength(2)) + + settlers[1].resolve([]) + await newer + settlers[0].reject(new Error("host busy")) + await older + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ error: expect.anything() }), + ) + }) + + it("keeps one view's refresh from discarding another view's refresh", async () => { + const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider + let finishFirst!: (models: []) => void + let calls = 0 + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation(() => + ++calls === 1 ? new Promise((resolve) => (finishFirst = resolve)) : Promise.resolve([]), + ) + + const refresh = passiveRequest() + await vi.waitFor(() => expect(calls).toBe(1)) + await webviewMessageHandler(otherView, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + finishFirst([]) + await refresh + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotModels", githubCopilotAccount: "Test User" }), + ) + }) + it("keeps one view's sign-in from discarding another view's refresh", async () => { const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index c9fb1b47c9..8b7b1724a0 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -3,6 +3,7 @@ import type { ModelInfo } from "@roo-code/types" import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" +import { disposeApiHandler } from "../../api/dispose-api-handler" import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -48,8 +49,11 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // info beforehand would build the preview from fallback metadata with different // tool guidance than the runtime path (which fetches before tool construction). let modelInfo: ModelInfo | undefined + // Built only to read model metadata. Some handlers (VS Code LM, GitHub Copilot) register + // host listeners, so release it however this ends: success, timeout, or error. + let tempApiHandler: ReturnType | undefined try { - const tempApiHandler = buildApiHandler(apiConfiguration) + tempApiHandler = buildApiHandler(apiConfiguration) // A failed OR stalled metadata fetch degrades to the handler's fallback // metadata (mirroring Task.safeEnsureModelFetched) rather than dropping // model guidance entirely, so the preview keeps matching the runtime @@ -79,6 +83,12 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web modelInfo = tempApiHandler.getModel().info } catch (error) { console.error("Error reading model info for system prompt preview:", error) + } finally { + try { + disposeApiHandler(tempApiHandler) + } catch (error) { + console.error("Error disposing the system prompt preview's API handler:", error) + } } const systemPrompt = await SYSTEM_PROMPT( diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6626425b8a..8f1e0ceb48 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1566,7 +1566,12 @@ export const webviewMessageHandler = async ( } const viewState = getGitHubCopilotViewState(provider) const changesAtStart = viewState.changes - const isOutOfDate = () => viewState.changes !== changesAtStart + // A refresh is out of date once a sign-in has moved the view on, or once a newer + // refresh for the same view has started: two overlapping refreshes see the same + // `changes`, so only the per-view refresh sequence can stop the older one, if it + // finishes last, from overwriting the newer list (or error). + const refreshId = ++viewState.refreshId + const isOutOfDate = () => viewState.changes !== changesAtStart || viewState.refreshId !== refreshId try { const githubCopilotAccount = await getGitHubCopilotAccount() const vsCodeLmModels = await getGitHubCopilotModels() diff --git a/src/services/language-models/copilotViewState.ts b/src/services/language-models/copilotViewState.ts index 70b04a456c..5fcef4ce83 100644 --- a/src/services/language-models/copilotViewState.ts +++ b/src/services/language-models/copilotViewState.ts @@ -3,6 +3,8 @@ type GitHubCopilotViewState = { signInId: number /** Moves whenever a sign-in starts or settles; anything that began earlier is out of date. */ changes: number + /** The newest passive model refresh, so an older overlapping refresh cannot overwrite a newer one. */ + refreshId: number } const viewStates = new WeakMap() @@ -11,7 +13,7 @@ const viewStates = new WeakMap() export function getGitHubCopilotViewState(view: object): GitHubCopilotViewState { let state = viewStates.get(view) if (!state) { - state = { signInId: 0, changes: 0 } + state = { signInId: 0, changes: 0, refreshId: 0 } viewStates.set(view, state) } return state From fd427d6fdfbf64a79165d50600af9c6cbe542d51 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Tue, 13 Oct 2026 22:19:59 -0700 Subject: [PATCH 30/33] chore(copilot): trim explanatory comments to stay within the mutation-gate line limit --- src/core/config/ProviderSettingsManager.ts | 4 +--- src/core/webview/generateSystemPrompt.ts | 2 -- src/core/webview/webviewMessageHandler.ts | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 0e7d829d58..01695d7994 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -595,9 +595,7 @@ export class ProviderSettingsManager { continue } - // Try to build an API handler to get model information. It is used only for - // that, and some handlers (VS Code LM, GitHub Copilot) register host listeners, - // so release it whether filtering succeeds or fails. + // Try to build an API handler to get model information let apiHandler: ReturnType | undefined try { apiHandler = buildApiHandler(configs[name]) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8b7b1724a0..1b2ffc57ab 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -49,8 +49,6 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // info beforehand would build the preview from fallback metadata with different // tool guidance than the runtime path (which fetches before tool construction). let modelInfo: ModelInfo | undefined - // Built only to read model metadata. Some handlers (VS Code LM, GitHub Copilot) register - // host listeners, so release it however this ends: success, timeout, or error. let tempApiHandler: ReturnType | undefined try { tempApiHandler = buildApiHandler(apiConfiguration) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8f1e0ceb48..5d10588feb 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1566,10 +1566,6 @@ export const webviewMessageHandler = async ( } const viewState = getGitHubCopilotViewState(provider) const changesAtStart = viewState.changes - // A refresh is out of date once a sign-in has moved the view on, or once a newer - // refresh for the same view has started: two overlapping refreshes see the same - // `changes`, so only the per-view refresh sequence can stop the older one, if it - // finishes last, from overwriting the newer list (or error). const refreshId = ++viewState.refreshId const isOutOfDate = () => viewState.changes !== changesAtStart || viewState.refreshId !== refreshId try { From 39c210348a890d1fc5bdde6a3efdfdd0f3f992c3 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Wed, 14 Oct 2026 07:30:54 -0700 Subject: [PATCH 31/33] fix(copilot): order manual model refreshes against host broadcasts A manual model refresh that started before a host model-change event could finish after the broadcast and replace its newer list with an older one: the broadcast did not advance anything the manual refresh checks. The broadcast now claims each view's next refresh generation, and posts only if no sign-in or newer refresh of that view happened meanwhile, so whichever of the two started last wins on both paths. Use the shared disposeApiHandler in Task instead of a local copy. Assert the delivered account and model list in the overlapping-refresh tests instead of only counting calls. --- src/core/task/Task.ts | 8 +- .../__tests__/webviewMessageHandler.spec.ts | 82 ++++++++++++++++--- .../__tests__/copilotModelBroadcast.spec.ts | 22 +++++ .../language-models/copilotModelBroadcast.ts | 12 ++- 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6099684058..6f03545dec 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,13 +63,7 @@ import { CloudService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" - -/** Releases host resources a handler owns, such as the VS Code LM / GitHub Copilot configuration listener. */ -function disposeApiHandler(handler: ApiHandler | undefined): void { - if (handler && "dispose" in handler && typeof handler.dispose === "function") { - handler.dispose() - } -} +import { disposeApiHandler } from "../../api/dispose-api-handler" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error" diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index ad87d2761c..6e25672edb 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -82,6 +82,7 @@ import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" import * as copilotProvider from "../../../api/providers/github-copilot" import { webviewMessageHandler } from "../webviewMessageHandler" +import { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" import type { ClineProvider } from "../ClineProvider" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" @@ -495,34 +496,42 @@ describe("webviewMessageHandler - Copilot authentication", () => { }) it("discards an older refresh's error that arrives after a newer refresh succeeded", async () => { - const settlers: Array<{ resolve: (models: []) => void; reject: (error: Error) => void }> = [] + const settlers: Array<{ + resolve: (models: Array<{ id: string }>) => void + reject: (error: Error) => void + }> = [] vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( - () => new Promise((resolve, reject) => settlers.push({ resolve, reject })), + () => + new Promise((resolve, reject) => + settlers.push({ resolve: resolve as (models: Array<{ id: string }>) => void, reject }), + ), ) const older = passiveRequest() const newer = passiveRequest() await vi.waitFor(() => expect(settlers).toHaveLength(2)) - settlers[1].resolve([]) + settlers[1].resolve([{ id: "newer" }]) await newer settlers[0].reject(new Error("host busy")) await older - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) - expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( - expect.objectContaining({ error: expect.anything() }), - ) + const posted = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls.map(([message]) => message) + expect(posted).toEqual([ + { type: "githubCopilotModels", githubCopilotAccount: "Test User", vsCodeLmModels: [{ id: "newer" }] }, + ]) }) it("keeps one view's refresh from discarding another view's refresh", async () => { const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider - let finishFirst!: (models: []) => void + let finishFirst!: (models: Array<{ id: string }>) => void let calls = 0 vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation(() => - ++calls === 1 ? new Promise((resolve) => (finishFirst = resolve)) : Promise.resolve([]), + ++calls === 1 + ? new Promise((resolve) => (finishFirst = resolve as (models: Array<{ id: string }>) => void)) + : Promise.resolve([{ id: "other-view" }] as never), ) const refresh = passiveRequest() @@ -531,12 +540,61 @@ describe("webviewMessageHandler - Copilot authentication", () => { type: "requestVsCodeLmModels", apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, }) - finishFirst([]) + finishFirst([{ id: "first-view" }]) await refresh - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( - expect.objectContaining({ type: "githubCopilotModels", githubCopilotAccount: "Test User" }), + const posted = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls.map(([message]) => message) + expect(posted).toEqual([ + { + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [{ id: "first-view" }], + }, + ]) + }) + + it("discards a manual refresh that a host model-change broadcast overtook", async () => { + let finishManual!: (models: Array<{ id: string }>) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( + () => new Promise((resolve) => (finishManual = resolve as (models: Array<{ id: string }>) => void)), ) + let notifyHost!: () => void + const broadcast = registerCopilotModelBroadcast({ + getTargets: () => [mockClineProvider], + log: vi.fn(), + debounceMs: 0, + subscribe: (listener: () => void) => { + notifyHost = listener + return { dispose: () => {} } + }, + fetchModels: async () => [{ id: "new-from-host" }] as never, + fetchAccount: async () => "Test User", + }) + try { + // A manual refresh starts first and is still waiting on the host. + const manual = passiveRequest() + await vi.waitFor(() => expect(finishManual).toBeDefined()) + + // The host then reports new models, and the broadcast delivers them. + notifyHost() + await vi.waitFor(() => expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1)) + + // The manual refresh finishes last, with the older list. + finishManual([{ id: "old-from-manual" }]) + await manual + + const posted = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls.map(([message]) => message) + expect(posted).toEqual([ + { + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [{ id: "new-from-host" }], + }, + ]) + } finally { + broadcast.dispose() + } }) it("keeps one view's sign-in from discarding another view's refresh", async () => { diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts index 4f02fcd348..e1919535b4 100644 --- a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -254,6 +254,28 @@ describe("registerCopilotModelBroadcast", () => { expect(bystander.postMessageToWebview).toHaveBeenCalledTimes(1) }) + it("is skipped when a manual refresh of that view started after the lookup began", async () => { + let finishModels: (models: Models) => void = () => {} + const fetchModels = vi + .fn() + .mockImplementation(() => new Promise((resolve) => (finishModels = resolve))) + const refreshed = newTarget() + const bystander = newTarget() + const { notify } = setup({ targets: [refreshed, bystander], fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + // The manual refresh path claims the view's next refresh, as the message handler does. + getGitHubCopilotViewState(refreshed).refreshId++ + finishModels([model("a")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(refreshed.postMessageToWebview).not.toHaveBeenCalled() + expect(bystander.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ vsCodeLmModels: [model("a")] }), + ) + }) + it("is served normally when the sign-in finished before the lookup began", async () => { const view = newTarget() getGitHubCopilotViewState(view).changes += 3 diff --git a/src/services/language-models/copilotModelBroadcast.ts b/src/services/language-models/copilotModelBroadcast.ts index 26aa0d3a7c..84d616b6f5 100644 --- a/src/services/language-models/copilotModelBroadcast.ts +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -64,8 +64,11 @@ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOpti const refresh = async () => { const request = ++latestRequest - const changesAtStart = new Map( - getTargets().map((target) => [target, getGitHubCopilotViewState(target).changes]), + const started = new Map( + getTargets().map((target) => { + const state = getGitHubCopilotViewState(target) + return [target, { changes: state.changes, refreshId: ++state.refreshId }] + }), ) try { const [vsCodeLmModels, account] = await Promise.all([fetchModels(), fetchAccount()]) @@ -74,8 +77,9 @@ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOpti await Promise.all( getTargets() .filter((target) => { - const started = changesAtStart.get(target) - return started === undefined || getGitHubCopilotViewState(target).changes === started + const atStart = started.get(target) + const state = getGitHubCopilotViewState(target) + return !atStart || (state.changes === atStart.changes && state.refreshId === atStart.refreshId) }) .map((target) => target.postMessageToWebview({ From b1a6459619fd56861bf608d00d8f50d779807e2a Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Wed, 14 Oct 2026 20:12:15 -0700 Subject: [PATCH 32/33] fix(copilot): keep a failed broadcast or account lookup from losing view state A broadcast claimed each view's refresh generation before its lookup succeeded. If the lookup then failed, or a newer broadcast superseded it and failed, nothing was posted, yet a manual refresh in flight saw its generation taken and dropped its own reply. The webview's refresh spinner never cleared and its buttons stayed disabled. The broadcast now records the generation at start and claims it only when it posts to a view. getGitHubCopilotAccount swallowed lookup failures and returned undefined, which callers reported as githubCopilotAccount: null, clearing the account label on a transient auth error. Let the failure throw: the broadcast logs it and keeps each view's last known state, and a manual refresh reports it as an error without touching the label. --- .../__tests__/github-copilot.spec.ts | 37 +++++++- src/api/providers/github-copilot.ts | 11 ++- .../__tests__/webviewMessageHandler.spec.ts | 87 +++++++++++++++++++ .../__tests__/copilotModelBroadcast.spec.ts | 12 +++ .../language-models/copilotModelBroadcast.ts | 12 +-- 5 files changed, 146 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts index 1bfb601baa..6e1a8b013b 100644 --- a/src/api/providers/__tests__/github-copilot.spec.ts +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -89,6 +89,7 @@ vi.mock("vscode", () => { }) import * as vscode from "vscode" +import { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" import { GitHubCopilotHandler, connectGitHubCopilot, @@ -226,9 +227,41 @@ describe("connectGitHubCopilot", () => { expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["read:user"], { silent: true }) }) - it("reports no account rather than failing when the lookup throws", async () => { + it("lets a failed lookup throw instead of reporting it as signed out", async () => { vi.mocked(vscode.authentication.getSession).mockRejectedValue(new Error("auth unavailable")) - await expect(getGitHubCopilotAccount()).resolves.toBeUndefined() + await expect(getGitHubCopilotAccount()).rejects.toThrow("auth unavailable") + }) + + it("keeps every view's account label when a broadcast's real account lookup fails", async () => { + vi.useFakeTimers() + try { + vi.mocked(vscode.authentication.getSession).mockRejectedValue(new Error("auth unavailable")) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + const view = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const log = vi.fn() + let notify!: () => void + const registration = registerCopilotModelBroadcast({ + getTargets: () => [view], + log, + debounceMs: 10, + subscribe: (listener) => { + notify = listener + return { dispose: () => {} } + }, + }) + try { + notify() + await vi.advanceTimersByTimeAsync(10) + } finally { + registration.dispose() + } + + // Posting `githubCopilotAccount: null` here would clear the label as if the user signed out. + expect(view.postMessageToWebview).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot state: auth unavailable") + } finally { + vi.useRealTimers() + } }) it("opens VS Code's own account management instead of signing out on its behalf", async () => { diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts index d5440cd25c..fd4e65f674 100644 --- a/src/api/providers/github-copilot.ts +++ b/src/api/providers/github-copilot.ts @@ -156,13 +156,12 @@ export function connectGitHubCopilot(onAuthenticated?: (account: string) => Prom return attempt } -/** The signed-in account, or undefined when there is none or the lookup is not possible. */ +/** + * The signed-in account, or undefined when there is none. A failed lookup throws rather than reading + * as "signed out", so callers keep the last known account instead of clearing it on a transient error. + */ export async function getGitHubCopilotAccount() { - try { - return (await getGitHubSession({ silent: true }))?.account.label - } catch { - return undefined - } + return (await getGitHubSession({ silent: true }))?.account.label } /** diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6e25672edb..99050b26c7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -597,6 +597,93 @@ describe("webviewMessageHandler - Copilot authentication", () => { } }) + describe("a broadcast that does not deliver", () => { + const pendingManual = () => { + let finishManual!: (models: Array<{ id: string }>) => void + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockImplementation( + () => new Promise((resolve) => (finishManual = resolve as (models: Array<{ id: string }>) => void)), + ) + return { finish: (models: Array<{ id: string }>) => finishManual(models) } + } + const broadcastWith = (fetchModels: () => Promise) => { + let notifyHost!: () => void + const registration = registerCopilotModelBroadcast({ + getTargets: () => [mockClineProvider], + log: vi.fn(), + debounceMs: 0, + subscribe: (listener: () => void) => { + notifyHost = listener + return { dispose: () => {} } + }, + fetchModels, + fetchAccount: async () => "Test User", + }) + return { notify: () => notifyHost(), registration } + } + const expectManualReplyDelivered = () => + expect( + vi.mocked(mockClineProvider.postMessageToWebview).mock.calls.map(([message]) => message), + ).toEqual([ + { + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [{ id: "manual" }], + }, + ]) + + it("still delivers a manual refresh when the broadcast's lookup fails", async () => { + const manualLookup = pendingManual() + const { notify, registration } = broadcastWith(() => Promise.reject(new Error("host busy"))) + try { + const manual = passiveRequest() + await vi.waitFor(() => expect(copilotProvider.getGitHubCopilotModels).toHaveBeenCalled()) + notify() + await new Promise((resolve) => setTimeout(resolve, 0)) + + manualLookup.finish([{ id: "manual" }]) + await manual + + // Otherwise the view's refresh spinner never clears and its buttons stay disabled. + expectManualReplyDelivered() + } finally { + registration.dispose() + } + }) + + it("still delivers a manual refresh when a newer broadcast supersedes the first and both fail", async () => { + const manualLookup = pendingManual() + const { notify, registration } = broadcastWith(() => Promise.reject(new Error("host busy"))) + try { + const manual = passiveRequest() + await vi.waitFor(() => expect(copilotProvider.getGitHubCopilotModels).toHaveBeenCalled()) + notify() + notify() + await new Promise((resolve) => setTimeout(resolve, 0)) + + manualLookup.finish([{ id: "manual" }]) + await manual + + expectManualReplyDelivered() + } finally { + registration.dispose() + } + }) + }) + + it("reports a thrown account lookup as an error, so the webview keeps its account label", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockRejectedValue(new Error("auth unavailable")) + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) + + await passiveRequest() + + // No githubCopilotAccount field: the webview leaves its label alone rather than clearing it. + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "githubCopilotModels", + error: "auth unavailable", + }) + }) + it("keeps one view's sign-in from discarding another view's refresh", async () => { const otherView = { ...mockClineProvider, postMessageToWebview: vi.fn() } as unknown as ClineProvider vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts index e1919535b4..b536243b18 100644 --- a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -276,6 +276,18 @@ describe("registerCopilotModelBroadcast", () => { ) }) + it("leaves the view's refresh claim untouched when its lookup fails", async () => { + const view = newTarget() + const before = getGitHubCopilotViewState(view).refreshId + const { notify } = setup({ targets: [view], fetchModels: vi.fn().mockRejectedValue(new Error("busy")) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(getGitHubCopilotViewState(view).refreshId).toBe(before) + expect(view.postMessageToWebview).not.toHaveBeenCalled() + }) + it("is served normally when the sign-in finished before the lookup began", async () => { const view = newTarget() getGitHubCopilotViewState(view).changes += 3 diff --git a/src/services/language-models/copilotModelBroadcast.ts b/src/services/language-models/copilotModelBroadcast.ts index 84d616b6f5..000fd6dbdc 100644 --- a/src/services/language-models/copilotModelBroadcast.ts +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -67,7 +67,7 @@ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOpti const started = new Map( getTargets().map((target) => { const state = getGitHubCopilotViewState(target) - return [target, { changes: state.changes, refreshId: ++state.refreshId }] + return [target, { changes: state.changes, refreshId: state.refreshId }] }), ) try { @@ -81,13 +81,15 @@ export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOpti const state = getGitHubCopilotViewState(target) return !atStart || (state.changes === atStart.changes && state.refreshId === atStart.refreshId) }) - .map((target) => - target.postMessageToWebview({ + .map((target) => { + // Claimed only when posting, so a failed or superseded lookup never strands a manual refresh. + getGitHubCopilotViewState(target).refreshId++ + return target.postMessageToWebview({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels, githubCopilotAccount: account ?? null, - }), - ), + }) + }), ) } catch (error) { log(`Failed to refresh GitHub Copilot state: ${error instanceof Error ? error.message : String(error)}`) From e682752130082ef417e0c83e309a18f2998f1f13 Mon Sep 17 00:00:00 2001 From: Karthick Mahalingam Date: Thu, 15 Oct 2026 07:40:48 -0700 Subject: [PATCH 33/33] chore: trigger CI