diff --git a/README.md b/README.md index fced229c9b..07c8e67de3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Zoo Code builds on the foundation created by Roo Code and continues to expand it ## 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/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 b78ab0e5b5..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: 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/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/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 cfbacde1d7..b6ba122d53 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/__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() diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index afe3d83e07..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, @@ -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, @@ -128,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 }) @@ -135,6 +156,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/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 d9413a31ba..d823bb6c95 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -31,6 +31,7 @@ import { KimiCodeHandler, MistralHandler, VsCodeLmHandler, + GitHubCopilotHandler, RequestyHandler, UnboundHandler, FakeAIHandler, @@ -184,6 +185,8 @@ export interface ApiHandler { getModelCacheScope(): ModelCacheScope } +export { disposeApiHandler } from "./dispose-api-handler" + export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration @@ -230,6 +233,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..6e1a8b013b --- /dev/null +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -0,0 +1,1364 @@ +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 { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" +import { + GitHubCopilotHandler, + connectGitHubCopilot, + getGitHubCopilotModels, + getGitHubCopilotAccount, + openGitHubAccountManagement, +} from "../github-copilot" +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. */ +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("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") + 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 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("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()).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 () => { + 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) + }) + + 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") + + 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("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", () => { + 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 + 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() + } + }) + }) + + 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 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(info).toStrictEqual(withoutCount) + }) + + 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) + }) + + 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("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", () => { + 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() + } + }) + + 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 } + } + + /** + * 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 { + const messages = handler.createMessage("system", [{ role: "user", content: "hi" }], { + taskId: "t", + abortSignal: controller.signal, + }) + await messages.next() + + // 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() + + 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() + }) + + 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.) + 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("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() + return 10 + }) + pendingStream() + const handler = new GitHubCopilotHandler(defaultOptions) + try { + 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() + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + } finally { + handler.dispose() + } + }) + + 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 { + 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() + // 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() + } + }) + + 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 () => ({ + 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) + }) + + 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("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* () { + 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 { + await handler.completePrompt("improve this", { abortSignal: controller.signal }) + } 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) + 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() + 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)) + yield new vscode.LanguageModelTextPart("late") + })(), + })) + 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) + 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", () => { + 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() + } + }) + }) +}) + +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 c15be285ca..b24284dde4 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, @@ -141,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", () => { @@ -171,6 +226,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")) @@ -4451,3 +4528,51 @@ 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("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) + + await expect(getVsCodeLmModels()).resolves.toEqual([model]) + }) + + it("asks the host for every model, whatever the vendor", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await getVsCodeLmModels() + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({}) + }) + + 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("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()).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 new file mode 100644 index 0000000000..fd4e65f674 --- /dev/null +++ b/src/api/providers/github-copilot.ts @@ -0,0 +1,173 @@ +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 { VSCODE_LM_STATIC_BLACKLIST, VsCodeLmHandler, containsImageBlock } 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 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 + } + + 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) { + // 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.", + ), + { 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), + ) +} + +/** + * 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>() + +/** + * 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) { + const pending = signInsInFlight.get(forceNewSession) + if (pending) return pending + + const attempt = (async (): Promise => { + try { + const { authProviderId, authScopeSets, chatExtensionId } = 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 getGitHubCopilotModels() } + } finally { + signInsInFlight.delete(forceNewSession) + } + })() + signInsInFlight.set(forceNewSession, attempt) + return attempt +} + +/** + * 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() { + return (await getGitHubSession({ silent: true }))?.account.label +} + +/** + * 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..4272f3e82a --- /dev/null +++ b/src/api/providers/vscode-lm-capabilities.ts @@ -0,0 +1,91 @@ +import * as vscode from "vscode" + +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + vscodeLlmBaselineModelInfo, + vscodeLlmModels, +} from "@roo-code/types" + +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. + * + * 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 = reportedCapabilities(model) + if (!capabilities) { + return undefined + } + for (const key of ["supportsImageToText", "imageInput"]) { + const reported: unknown = Reflect.get(capabilities, key) + if (typeof reported === "boolean") { + return reported + } + } + 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. + */ +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, + // 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 7326d3ec8a..5dee3f2acf 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -51,6 +51,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 +773,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 +796,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 +855,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 +1011,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 +1060,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 +1074,19 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - private async getClient(): Promise { + /** + * 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:", { vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, @@ -1071,12 +1147,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,14 +1198,22 @@ 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 - this.currentRequestCancellation = new vscode.CancellationTokenSource() - - // Calculate input tokens before starting the stream - const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) + // Initialize cancellation token for the request. The caller's abort signal is + // 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 + // 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. let accumulatedText: string = "" @@ -1213,16 +1299,34 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } try { + // 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.`, tools: convertToVsCodeLmTools(metadata?.tools ?? []), } - 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 @@ -1339,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) { + if (error instanceof vscode.CancellationError || abortedByCaller) { throw new Error("Zoo Code : Request cancelled by user") } @@ -1365,6 +1471,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) } } @@ -1391,22 +1500,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 +1513,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return { id: fallbackId, info: { - ...openAiModelInfoSaneDefaults, + ...this.getFallbackModelInfo(), description: `VSCode Language Model (Fallback): ${fallbackId}`, }, } @@ -1445,13 +1539,31 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return this.getModel().info.contextWindow } - async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { + /** 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 { + 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)], - {}, - new vscode.CancellationTokenSource().token, + this.getCompletionRequestOptions(client), + cancellation.token, ) let result = "" for await (const chunk of response.stream) { @@ -1465,12 +1577,16 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan throw new Error(`VSCode LM completion error: ${error.message}`) } throw error + } finally { + if (timer) clearTimeout(timer) + abortSignal?.removeEventListener("abort", cancel) + cancellation.dispose() } } } // 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"] export async function getVsCodeLmModels() { try { diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index f55bf0356a..f3dc99a4a7 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -93,10 +93,106 @@ 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("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( + [ + { + 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/__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/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/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index e0f272a578..01695d7994 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 @@ -595,8 +596,9 @@ export class ProviderSettingsManager { } // Try to build an API handler to get model information + 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 +621,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/Task.ts b/src/core/task/Task.ts index df38fb4309..b30ddc49b7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,6 +63,7 @@ import { CloudService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" +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" @@ -411,6 +412,14 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: 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 @@ -2466,9 +2475,14 @@ 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 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) + this.retireApi(previousApi) // Refresh only the soft tool-repetition limit from the new profile so an // active task immediately enforces it. The task-level @@ -2484,6 +2498,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[], @@ -2550,6 +2602,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() @@ -3549,6 +3610,15 @@ 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). + // 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) + } + // 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. @@ -4548,6 +4618,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). @@ -5471,6 +5547,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 85325b39ff..3066613263 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> @@ -1777,6 +1780,323 @@ 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() + }) + + /** + * 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) + + 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() + + 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() + }) + + 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, + 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/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__/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 c57e9f3ac3..99050b26c7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -78,8 +78,11 @@ 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 { 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" @@ -133,6 +136,597 @@ 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("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("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" }) + 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("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 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(account).not.toHaveBeenCalled() + expect(copilotModels).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + vsCodeLmModels: [], + }) + }) + }) + + it("restores a signed-in account during passive model refresh", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(copilotProvider.getGitHubCopilotModels).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + }) + + 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([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: null, + vsCodeLmModels: [], + }) + }) + + 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(copilotProvider, "getGitHubCopilotModels").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(copilotProvider, "getGitHubCopilotModels").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(copilotProvider, "getGitHubCopilotModels").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(copilotProvider, "getGitHubCopilotModels").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(copilotProvider, "getGitHubCopilotModels").mockResolvedValue([]) + + await passiveRequest() + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + }) + + 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: 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: resolve as (models: Array<{ id: string }>) => void, reject }), + ), + ) + + const older = passiveRequest() + const newer = passiveRequest() + await vi.waitFor(() => expect(settlers).toHaveLength(2)) + + settlers[1].resolve([{ id: "newer" }]) + await newer + settlers[0].reject(new Error("host busy")) + 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("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: 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 as (models: Array<{ id: string }>) => void)) + : Promise.resolve([{ id: "other-view" }] as never), + ) + + const refresh = passiveRequest() + await vi.waitFor(() => expect(calls).toBe(1)) + await webviewMessageHandler(otherView, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + finishFirst([{ id: "first-view" }]) + await refresh + + 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() + } + }) + + 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") + vi.spyOn(copilotProvider, "getGitHubCopilotModels").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("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")) + 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/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index c9fb1b47c9..1b2ffc57ab 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,9 @@ 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 + 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 +81,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 13af5fc731..5d10588feb 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -80,6 +80,13 @@ 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" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -1508,11 +1515,77 @@ 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: { + // 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 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 + 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), + }) + } finally { + if (isLatest()) viewState.changes++ + } + 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: { + 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 refreshId = ++viewState.refreshId + const isOutOfDate = () => viewState.changes !== changesAtStart || viewState.refreshId !== refreshId + try { + const githubCopilotAccount = await getGitHubCopilotAccount() + const vsCodeLmModels = await getGitHubCopilotModels() + if (isOutOfDate()) break + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels, + githubCopilotAccount: githubCopilotAccount ?? null, + }) + } catch (error) { + if (isOutOfDate()) break + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + 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.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 new file mode 100644 index 0000000000..b536243b18 --- /dev/null +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -0,0 +1,409 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" + +import { VsCodeLmModelsMessageType } from "@roo-code/types" + +import { registerCopilotModelBroadcast, type CopilotModelBroadcastTarget } from "../copilotModelBroadcast" +import { getGitHubCopilotViewState } from "../copilotViewState" + +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> + +const model = (id: string) => ({ id, vendor: "copilot", family: id, version: "1", name: id, maxInputTokens: 1 }) +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 = 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, + log, + debounceMs: 100, + subscribe: (listener) => { + notify = listener + return subscription as never + }, + fetchModels: fetchModels as never, + fetchAccount: fetchAccount as never, + }) + return { notify: () => notify(), registration, subscription, target, targets, fetchModels, fetchAccount, log } +} + +describe("registerCopilotModelBroadcast", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + 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(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() + + 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 = newTarget() + 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 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).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") + }) + + 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") }) + + 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 () => { + const { notify, target } = setup({ fetchModels: vi.fn().mockResolvedValue([]) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [], + githubCopilotAccount: "octocat", + }) + }) + + 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")] }), + ) + }) + + 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 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("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 + 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() + + 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() + }) + + describe("default host wiring", () => { + const modelEvent = vi.mocked(vscode.lm.onDidChangeChatModels) + const sessionEvent = vi.mocked(vscode.authentication.onDidChangeSessions) + const raise = { + models: () => {}, + sessions: (_providerId: string) => {}, + } + + beforeEach(() => { + modelEvent.mockReset() + sessionEvent.mockReset() + modelEvent.mockImplementation(((listener: () => void) => { + raise.models = listener + return { dispose: vi.fn() } + }) as never) + sessionEvent.mockImplementation(((listener: (event: { provider: { id: string } }) => void) => { + raise.sessions = (id) => listener({ provider: { id } }) + return { dispose: vi.fn() } + }) as never) + }) + + const register = () => { + const fetchModels = vi.fn().mockResolvedValue([]) + const registration = registerCopilotModelBroadcast({ + getTargets: () => [], + log: vi.fn(), + debounceMs: 10, + fetchModels: fetchModels as never, + fetchAccount: vi.fn().mockResolvedValue("octocat") as never, + }) + 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("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 new file mode 100644 index 0000000000..000fd6dbdc --- /dev/null +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -0,0 +1,111 @@ +import * as vscode from "vscode" + +import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" + +import { getGitHubCopilotAccount, getGitHubCopilotModels } from "../../api/providers/github-copilot" +import { getGitHubCopilotViewState } from "./copilotViewState" + +export interface CopilotModelBroadcastTarget { + postMessageToWebview(message: { + type: typeof VsCodeLmModelsMessageType.githubCopilotModels + vsCodeLmModels: Awaited> + githubCopilotAccount: string | null + }): 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 + /** Calls the listener when the host's models or its GitHub sessions change. */ + subscribe?: (listener: () => void) => vscode.Disposable | undefined + 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 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 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 = subscribeToHost, + fetchModels = getGitHubCopilotModels, + fetchAccount = getGitHubCopilotAccount, + } = options + + let timer: ReturnType | undefined + let latestRequest = 0 + let disposed = false + + const refresh = async () => { + const request = ++latestRequest + 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()]) + // A newer change superseded this lookup, or the extension is shutting down. + if (disposed || request !== latestRequest) return + await Promise.all( + getTargets() + .filter((target) => { + const atStart = started.get(target) + const state = getGitHubCopilotViewState(target) + return !atStart || (state.changes === atStart.changes && state.refreshId === atStart.refreshId) + }) + .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)}`) + } + } + + 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/services/language-models/copilotViewState.ts b/src/services/language-models/copilotViewState.ts new file mode 100644 index 0000000000..5fcef4ce83 --- /dev/null +++ b/src/services/language-models/copilotViewState.ts @@ -0,0 +1,20 @@ +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 + /** The newest passive model refresh, so an older overlapping refresh cannot overwrite a newer one. */ + refreshId: 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, refreshId: 0 } + viewStates.set(view, state) + } + return state +} 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/src/utils/__tests__/enhance-prompt.spec.ts b/src/utils/__tests__/enhance-prompt.spec.ts index 755e9df7d7..83c2bd12d3 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,6 +47,33 @@ describe("enhancePrompt", () => { expect((handler as any).completePrompt).toHaveBeenCalledWith(`Test prompt`, undefined) }) + 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(failing.dispose).toHaveBeenCalledOnce() + 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}" diff --git a/src/utils/single-completion-handler.ts b/src/utils/single-completion-handler.ts index 4890b38471..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. @@ -20,10 +20,15 @@ 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. + disposeApiHandler(handler) + } } diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 4bb3c5802f..51b1941b1f 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -280,6 +280,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/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..1ae893eeb2 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,49 @@ export const ModelInfoView = ({ {modelInfo.maxTokens?.toLocaleString()} tokens ), - , - , + apiProvider === providerIdentifiers.githubCopilot && modelInfo?.supportsImages === undefined ? ( +
+
+ ) : ( + + ), + // 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 ? ( + +
+
+
+ ) : ( + + ), apiProvider === providerIdentifiers.gemini && ( {selectedModelId.includes("pro-preview") 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__/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__/GitHubCopilotSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx new file mode 100644 index 0000000000..fa0dc94d0c --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx @@ -0,0 +1,64 @@ +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 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 } } + +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 expect(component.getByText("Tool 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__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx index 1e66b03255..ac87f06d7a 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -8,6 +8,14 @@ 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.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", "settings:serviceTier.columns.tier": "Tier", "settings:serviceTier.columns.input": "Input", @@ -43,6 +51,116 @@ 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(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.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( + , + ) + 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/__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 0000000000..5af7f97876 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png new file mode 100644 index 0000000000..7310e92101 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png differ 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 new file mode 100644 index 0000000000..3cc7172014 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png differ 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..b14c38c9a7 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -1,75 +1,218 @@ 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) + } + // 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) + // 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], + ) 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.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: 0, + contextWindow: model.maxInputTokens ?? 0, supportsPromptCache: false, + displayName: model.name, description: `${model.vendor} - ${model.family}`, + ...model.modelInfo, } return acc }, {} as Record, ) - }, [vsCodeLmModels]) + }, [vsCodeLmModels, isCopilot]) - // 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 = isCopilot ? vsCodeLmModels.find((candidate) => candidate.id === modelId) : undefined + if (model) { + return { id: model.id, vendor: model.vendor, family: model.family, version: model.version } + } + const [vendor, family] = modelId.split("/") + return { vendor, family } + }, + [vsCodeLmModels, isCopilot], + ) - // 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 (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, isCopilot], + ) 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", + )}
)} -
{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 e355baa08c..d165ead136 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,675 @@ -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 } + + 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("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( + , + ) + + 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) + }) + + 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( + , + ) + + // 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", name: "Model One" }, + { vendor: "other", family: "no-id" }, + ] + + 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", + }) + }) + + 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" }) + }) + + 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", () => { + 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("GitHub Copilot", () => { + beforeEach(async () => { + mount(providerIdentifiers.githubCopilot) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: listed }) + await waitFor(() => expect(modelPickerMock).toHaveBeenCalled()) + }) + + 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", + family: "fam", + version: "1", + }) + }) + + it("saves 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 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.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( + , + ) + 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 0ae95e8c73..4c96b0d081 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,169 @@ 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("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, + ).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, + ).toBeUndefined() + }) + + 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 }, + }) + + 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, + }) + }) + + 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("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("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 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).toBeUndefined() + }) + + 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..b8ae7253e1 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,37 @@ 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, + // The extension enforces the curated input limit, so the UI must not show the larger window. + ...(knownModel && { contextWindow: knownModel.maxInputTokens }), + ...reported?.modelInfo, + // 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, + }, + } + } case providerIdentifiers.vscodeLm: { const id = apiConfiguration?.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index f599e40db9..8fd84b3233 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Compatibilitat amb eines 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", + "supportsTools": "Suporta eines", + "noTools": "No suporta eines", "supportsPromptCache": "Suporta emmagatzematge en caché de prompts", "noPromptCache": "No suporta emmagatzematge en caché de prompts", "contextWindow": "Finestra de context:", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 845c9e575f..f30249b8c9 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,8 +1012,14 @@ "rangeDescription": "Höhere Werte machen die Ausgabe zufälliger, niedrigere Werte machen sie deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Bildunterstützung nicht gemeldet", + "toolSupportUnknown": "Tool-Unterstü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", + "supportsTools": "Unterstützt Tools", + "noTools": "Unterstützt keine Tools", "supportsPromptCache": "Unterstützt Prompt-Cache", "noPromptCache": "Unterstützt keinen Prompt-Cache", "contextWindow": "Kontextfenster:", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index de36f7ac3a..fb2ef0e716 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,8 +1092,14 @@ "rangeDescription": "Higher values make output more random, lower values make it more deterministic." }, "modelInfo": { + "imageSupportUnknown": "Image support not reported", + "toolSupportUnknown": "Tool 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", + "supportsTools": "Supports tools", + "noTools": "Does not support tools", "supportsPromptCache": "Supports prompt caching", "noPromptCache": "Does not support prompt caching", "contextWindow": "Context Window:", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 1e6f87d553..e2e05770ae 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Compatibilidad con herramientas 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", + "supportsTools": "Soporta herramientas", + "noTools": "No soporta herramientas", "supportsPromptCache": "Soporta caché de prompts", "noPromptCache": "No soporta caché de prompts", "contextWindow": "Ventana de contexto", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ec6ee0f692..69f99e83fe 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Prise en charge des outils 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", + "supportsTools": "Prend en charge les outils", + "noTools": "Ne prend pas en charge les outils", "supportsPromptCache": "Prend en charge la mise en cache des prompts", "noPromptCache": "Ne prend pas en charge la mise en cache des prompts", "contextWindow": "Fenêtre de contexte :", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index fa725cd2c5..c124082e2a 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,8 +1012,14 @@ "rangeDescription": "उच्च मान आउटपुट को अधिक यादृच्छिक बनाते हैं, निम्न मान इसे अधिक निर्धारित बनाते हैं।" }, "modelInfo": { + "imageSupportUnknown": "छवि समर्थन की जानकारी नहीं दी गई", + "toolSupportUnknown": "टूल समर्थन की जानकारी नहीं दी गई", + "promptCacheManagedByCopilot": "प्रॉम्प्ट कैशिंग GitHub Copilot द्वारा प्रबंधित है", + "promptCacheCopilotDetails": "कैशिंग मॉडल और GitHub Copilot बैकएंड पर निर्भर करती है। Zoo Code VS Code API के माध्यम से कैश प्रतिधारण को कॉन्फ़िगर नहीं कर सकता और न ही कैश हिट देख सकता है।", "supportsImages": "छवियों का समर्थन करता है", "noImages": "छवियों का समर्थन नहीं करता है", + "supportsTools": "टूल का समर्थन करता है", + "noTools": "टूल का समर्थन नहीं करता है", "supportsPromptCache": "प्रॉम्प्ट कैशिंग का समर्थन करता है", "noPromptCache": "प्रॉम्प्ट कैशिंग का समर्थन नहीं करता है", "contextWindow": "संदर्भ विंडो:", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bd3e0fcc96..e5ff23782a 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,8 +1012,14 @@ "rangeDescription": "Nilai yang lebih tinggi membuat output lebih acak, nilai yang lebih rendah membuatnya lebih deterministik." }, "modelInfo": { + "imageSupportUnknown": "Dukungan gambar tidak dilaporkan", + "toolSupportUnknown": "Dukungan alat 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", + "supportsTools": "Mendukung alat", + "noTools": "Tidak mendukung alat", "supportsPromptCache": "Mendukung prompt caching", "noPromptCache": "Tidak mendukung prompt caching", "contextWindow": "Jendela Konteks:", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 2b604e720f..550a72d1c8 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,8 +1012,14 @@ "rangeDescription": "Valori più alti rendono l'output più casuale, valori più bassi lo rendono più deterministico." }, "modelInfo": { + "imageSupportUnknown": "Supporto per le immagini non segnalato", + "toolSupportUnknown": "Supporto per gli strumenti 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", + "supportsTools": "Supporta strumenti", + "noTools": "Non supporta strumenti", "supportsPromptCache": "Supporta cache dei prompt", "noPromptCache": "Non supporta cache dei prompt", "contextWindow": "Finestra di contesto:", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 2ca3ef0396..098fdd598c 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,8 +1012,14 @@ "rangeDescription": "高い値は出力をよりランダムに、低い値はより決定論的にします。" }, "modelInfo": { + "imageSupportUnknown": "画像サポートが報告されていません", + "toolSupportUnknown": "ツールサポートが報告されていません", + "promptCacheManagedByCopilot": "プロンプトキャッシュは GitHub Copilot が管理します", + "promptCacheCopilotDetails": "キャッシュの動作はモデルと GitHub Copilot のバックエンドに依存します。Zoo Code は VS Code API を通じて、キャッシュの保持期間を設定したりキャッシュヒットを確認したりすることはできません。", "supportsImages": "画像をサポート", "noImages": "画像をサポートしていません", + "supportsTools": "ツールをサポート", + "noTools": "ツールをサポートしていません", "supportsPromptCache": "プロンプトキャッシュをサポート", "noPromptCache": "プロンプトキャッシュをサポートしていません", "contextWindow": "コンテキストウィンドウ:", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 89dcd74c40..aa773ab96f 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,8 +1012,14 @@ "rangeDescription": "높은 값은 출력을 더 무작위하게, 낮은 값은 더 결정적으로 만듭니다." }, "modelInfo": { + "imageSupportUnknown": "이미지 지원 여부가 보고되지 않음", + "toolSupportUnknown": "도구 지원 여부가 보고되지 않음", + "promptCacheManagedByCopilot": "프롬프트 캐싱은 GitHub Copilot이 관리합니다", + "promptCacheCopilotDetails": "캐싱은 모델과 GitHub Copilot 백엔드에 따라 달라집니다. Zoo Code는 VS Code API를 통해 캐시 보존 기간을 설정하거나 캐시 적중 여부를 확인할 수 없습니다.", "supportsImages": "이미지 지원", "noImages": "이미지 지원 안 함", + "supportsTools": "도구 지원", + "noTools": "도구 지원 안 함", "supportsPromptCache": "프롬프트 캐시 지원", "noPromptCache": "프롬프트 캐시 지원 안 함", "contextWindow": "컨텍스트 창:", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2eeea89e92..be2e420290 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,8 +1012,14 @@ "rangeDescription": "Hogere waarden maken de output willekeuriger, lagere waarden maken deze deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Ondersteuning voor afbeeldingen niet gemeld", + "toolSupportUnknown": "Ondersteuning voor tools 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", + "supportsTools": "Ondersteunt tools", + "noTools": "Ondersteunt geen tools", "supportsPromptCache": "Ondersteunt prompt caching", "noPromptCache": "Ondersteunt geen prompt caching", "contextWindow": "Contextvenster:", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 0de3a36c4e..084eb65465 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Obsługa narzędzi 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", + "supportsTools": "Obsługuje narzędzia", + "noTools": "Nie obsługuje narzędzi", "supportsPromptCache": "Obsługuje buforowanie podpowiedzi", "noPromptCache": "Nie obsługuje buforowania podpowiedzi", "contextWindow": "Okno kontekstowe:", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 893504febe..cd9ee4b88e 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Suporte a ferramentas 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", + "supportsTools": "Suporta ferramentas", + "noTools": "Não suporta ferramentas", "supportsPromptCache": "Suporta cache de prompts", "noPromptCache": "Não suporta cache de prompts", "contextWindow": "Janela de Contexto:", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 3d55ee12d3..dc108f4d83 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,8 +1012,14 @@ "rangeDescription": "Более высокие значения делают ответы более случайными, низкие — более детерминированными." }, "modelInfo": { + "imageSupportUnknown": "Поддержка изображений не сообщена", + "toolSupportUnknown": "Поддержка инструментов не сообщена", + "promptCacheManagedByCopilot": "Кэшированием промптов управляет GitHub Copilot", + "promptCacheCopilotDetails": "Кэширование зависит от модели и серверной части GitHub Copilot. Zoo Code не может настроить срок хранения кэша или отслеживать попадания в кэш через API VS Code.", "supportsImages": "Поддерживает изображения", "noImages": "Не поддерживает изображения", + "supportsTools": "Поддерживает инструменты", + "noTools": "Не поддерживает инструменты", "supportsPromptCache": "Поддерживает кэширование подсказок", "noPromptCache": "Не поддерживает кэширование подсказок", "contextWindow": "Контекстное окно:", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5f93ec8035..f3cd95d80e 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Araç 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", + "supportsTools": "Araçları destekler", + "noTools": "Araçları desteklemez", "supportsPromptCache": "İstem önbelleğini destekler", "noPromptCache": "İstem önbelleğini desteklemez", "contextWindow": "Bağlam Penceresi:", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 5ec6e8c105..0afb4eb81a 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,8 +1012,14 @@ "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", + "toolSupportUnknown": "Chưa báo cáo khả năng hỗ trợ công cụ", + "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", + "supportsTools": "Hỗ trợ công cụ", + "noTools": "Không hỗ trợ công cụ", "supportsPromptCache": "Hỗ trợ bộ nhớ đệm lời nhắc", "noPromptCache": "Không hỗ trợ bộ nhớ đệm lời nhắc", "contextWindow": "Cửa sổ ngữ cảnh:", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9e0a94d908..c21383f632 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,8 +1012,14 @@ "rangeDescription": "值越高回答越多样,值越低越保守" }, "modelInfo": { + "imageSupportUnknown": "未报告图像支持情况", + "toolSupportUnknown": "未报告工具支持情况", + "promptCacheManagedByCopilot": "提示词缓存由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "缓存行为取决于模型和 GitHub Copilot 后端。Zoo Code 无法通过 VS Code API 配置缓存保留时间,也无法观察缓存命中情况。", "supportsImages": "支持图像", "noImages": "不支持图像", + "supportsTools": "支持工具", + "noTools": "不支持工具", "supportsPromptCache": "支持提示缓存", "noPromptCache": "不支持提示缓存", "contextWindow": "上下文窗口:", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c2fd65c755..619bb1fdec 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,8 +1039,14 @@ "rangeDescription": "較高值使輸出更隨機,較低值更確定" }, "modelInfo": { + "imageSupportUnknown": "未回報圖片支援情況", + "toolSupportUnknown": "未回報工具支援情況", + "promptCacheManagedByCopilot": "提示詞快取由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "快取行為取決於模型與 GitHub Copilot 後端。Zoo Code 無法透過 VS Code API 設定快取保留時間,也無法觀察快取命中情況。", "supportsImages": "支援影像", "noImages": "不支援影像", + "supportsTools": "支援工具", + "noTools": "不支援工具", "supportsPromptCache": "支援提示快取", "noPromptCache": "不支援提示快取", "contextWindow": "上下文視窗:", 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 = { 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")