diff --git a/README.md b/README.md index 9c4cdede7e1..66b57287bdc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, GitHub Copilot, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,10 +13,11 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, GitHub Copilot (Early Access), Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` +> - GitHub Copilot: install [Copilot CLI](https://github.com/github/copilot-cli) and run `copilot login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` @@ -74,6 +75,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- [GitHub Copilot provider](./docs/user/providers-copilot.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c4595..a782bffa951 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -49,6 +49,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "copilot") { + return ( + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index f15dc01975f..61391798d92 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -74,10 +74,11 @@ const permissionOptionIds = { allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", }; +const permissionToolKind = process.env.T3_ACP_PERMISSION_TOOL_KIND === "edit" ? "edit" : "execute"; const sessionId = "mock-session-1"; let currentModeId = "ask"; -let currentModelId = "default"; +let currentModelId = process.env.T3_ACP_INITIAL_MODEL_ID?.trim() || "default"; let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; @@ -982,7 +983,7 @@ const program = Effect.gen(function* () { sessionUpdate: "tool_call", toolCallId, title: "Terminal", - kind: "execute", + kind: permissionToolKind, status: "pending", rawInput: { command: ["cat", "server/package.json"], @@ -1004,7 +1005,7 @@ const program = Effect.gen(function* () { toolCall: { toolCallId, title: "`cat server/package.json`", - kind: "execute", + kind: permissionToolKind, status: "pending", content: [ { @@ -1041,7 +1042,7 @@ const program = Effect.gen(function* () { sessionUpdate: "tool_call_update", toolCallId, title: "Terminal", - kind: "execute", + kind: permissionToolKind, status: "completed", rawOutput: { exitCode: 0, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 4704e7489af..37c2670bc16 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -202,6 +202,12 @@ export interface AcpAdapterV2Flavor { Crypto.Crypto | Scope.Scope >; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; + readonly configureSession?: (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly startResult: AcpSessionRuntimeStartResult; + readonly modelSelection: ModelSelection; + readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + }) => Effect.Effect; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, ) => Effect.Effect; @@ -4597,6 +4603,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV modelSelection: ModelSelection, runtimePolicy: ProviderAdapterV2RuntimePolicy, ) { + if (flavor.configureSession !== undefined) { + return yield* flavor.configureSession({ + runtime, + startResult, + modelSelection, + runtimePolicy, + }); + } const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; if ( requestedModel.length > 0 && diff --git a/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts new file mode 100644 index 00000000000..5a6ac7d70fb --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.test.ts @@ -0,0 +1,168 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import type { AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; +import { + COPILOT_DRIVER_KIND, + CopilotAdapterV2Driver, + makeCopilotAdapterV2, + makeCopilotAcpAdapterFlavor, + type CopilotAdapterV2Options, +} from "./CopilotAdapterV2.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-copilot-v2-adapter-", +}).pipe(Layer.provide(NodeServices.layer)); + +const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer); + +function makeMockRuntime(input: { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly mockAgentPath: string; +}) { + return (runtimeInput: AcpAdapterV2RuntimeInput) => + Effect.gen(function* () { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeInput, + spawn: { + command: process.execPath, + args: [input.mockAgentPath], + cwd: runtimeInput.cwd, + env: { T3_ACP_SESSION_LIFECYCLE: "1" }, + }, + authMethodId: "test", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(context), + ); + return runtime; + }); +} + +describe("CopilotAdapterV2", () => { + it("is registered with Copilot schema defaults", () => { + assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(COPILOT_DRIVER_KIND)); + assert.equal(CopilotAdapterV2Driver.driverKind, COPILOT_DRIVER_KIND); + assert.deepEqual(CopilotAdapterV2Driver.defaultConfig(), { + enabled: true, + binaryPath: "copilot", + customModels: [], + }); + }); + + it.effect("keeps Copilot in agent mode even for a plan interaction request", () => + Effect.gen(function* () { + const selectedModes: Array = []; + const instanceId = ProviderInstanceId.make("copilot-mode-fixture"); + const flavor = makeCopilotAcpAdapterFlavor({ + makeRuntime: () => Effect.never, + } as unknown as CopilotAdapterV2Options); + const runtime = { + getConfigOptions: Effect.succeed([]), + getModeState: Effect.succeed({ + currentModeId: "plan", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "plan", name: "Plan" }, + ], + }), + setConfigOption: () => Effect.succeed({ configOptions: [] }), + setMode: (modeId: string) => + Effect.sync(() => { + selectedModes.push(modeId); + return {}; + }), + setModel: () => Effect.void, + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; + const configureSession = flavor.configureSession; + if (configureSession === undefined) { + return yield* Effect.die("Expected Copilot ACP session configuration"); + } + + yield* configureSession({ + runtime, + startResult: {} as AcpSessionRuntime.AcpSessionRuntimeStartResult, + modelSelection: { instanceId, model: "auto" }, + runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "plan", + cwd: process.cwd(), + }), + }); + + assert.deepEqual(selectedModes, ["agent"]); + assert.isFalse(flavor.capabilities.planning.emitsProposedPlan); + }), + ); + + it.effect("opens a Copilot V2 session through the shared ACP adapter", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("copilot-fixture"); + const adapter = makeCopilotAdapterV2({ + instanceId, + settings: { + enabled: true, + binaryPath: "copilot", + customModels: [], + }, + environment: {}, + childProcessSpawner, + crypto: yield* Crypto.Crypto, + fileSystem, + idAllocator, + serverConfig, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + }), + }); + const threadId = ThreadId.make("thread-copilot-fixture"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "plan", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-copilot-fixture"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + assert.equal(runtime.providerSession.driver, "copilot"); + assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts new file mode 100644 index 00000000000..a8603ffb4ae --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/CopilotAdapterV2.ts @@ -0,0 +1,177 @@ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + CopilotSettings, + defaultInstanceIdForDriver, + ProviderDriverKind, + type OrchestrationV2ProviderCapabilities, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { ServerConfig } from "../../config.ts"; +import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import { + applyCopilotSessionConfiguration, + makeCopilotAcpRuntime, + resolveCopilotModeId, + resolveCopilotModelId, +} from "../../provider/acp/CopilotAcpSupport.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2Flavor, + type AcpAdapterV2RuntimeInput, +} from "./AcpAdapterV2.ts"; + +export const COPILOT_PROVIDER = ProviderDriverKind.make("copilot"); +export const COPILOT_DRIVER_KIND = COPILOT_PROVIDER; +export const COPILOT_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(COPILOT_DRIVER_KIND); + +const DEFAULT_COPILOT_SETTINGS = Schema.decodeSync(CopilotSettings)({}); + +export const CopilotProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + planning: { + ...AcpProviderCapabilitiesV2.planning, + emitsProposedPlan: false, + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface CopilotAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: CopilotSettings; + readonly environment: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +export function makeCopilotAcpAdapterFlavor(options: CopilotAdapterV2Options): AcpAdapterV2Flavor { + return { + driver: COPILOT_PROVIDER, + capabilities: CopilotProviderCapabilitiesV2, + resolveModelId: (selection) => resolveCopilotModelId(selection.model), + makeRuntime: + options.makeRuntime ?? + ((input) => + makeCopilotAcpRuntime({ + ...input, + copilotSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })), + configureSession: ({ runtime, modelSelection, runtimePolicy }) => + Effect.gen(function* () { + yield* applyCopilotSessionConfiguration({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause }) => cause, + }); + const modeState = yield* runtime.getModeState; + const modeId = resolveCopilotModeId({ + modeState, + interactionMode: runtimePolicy.interactionMode, + runtimeMode: runtimePolicy.runtimeMode, + }); + if (modeId !== undefined && modeId !== modeState?.currentModeId) { + yield* runtime.setMode(modeId); + } + }), + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }; +} + +export function makeCopilotAdapterV2(options: CopilotAdapterV2Options) { + return makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: makeCopilotAcpAdapterFlavor(options), + crypto: options.crypto, + fileSystem: options.fileSystem, + idAllocator: options.idAllocator, + serverConfig: options.serverConfig, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +} + +export type CopilotAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const CopilotAdapterV2Driver: ProviderAdapterDriver< + CopilotSettings, + CopilotAdapterV2DriverEnv +> = { + driverKind: COPILOT_DRIVER_KIND, + configSchema: CopilotSettings, + defaultConfig: (): CopilotSettings => DEFAULT_COPILOT_SETTINGS, + create: Effect.fn("CopilotAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return makeCopilotAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + childProcessSpawner, + crypto, + fileSystem, + idAllocator, + serverConfig, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: COPILOT_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: COPILOT_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create GitHub Copilot ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0..8e27742736e 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -9,6 +9,10 @@ import { type ClaudeAdapterV2DriverEnv, } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/CodexAdapterV2.ts"; +import { + CopilotAdapterV2Driver, + type CopilotAdapterV2DriverEnv, +} from "./Adapters/CopilotAdapterV2.ts"; import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv, @@ -24,6 +28,7 @@ export type BuiltInProviderAdapterDriversV2Env = | AcpRegistryAdapterV2DriverEnv | ClaudeAdapterV2DriverEnv | CodexAdapterV2DriverEnv + | CopilotAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv | OpenCodeAdapterV2DriverEnv; @@ -33,6 +38,7 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< > = [ CodexAdapterV2Driver, ClaudeAdapterV2Driver, + CopilotAdapterV2Driver, CursorAdapterV2Driver, OpenCodeAdapterV2Driver, GrokAdapterV2Driver, diff --git a/apps/server/src/provider/Drivers/CopilotDriver.test.ts b/apps/server/src/provider/Drivers/CopilotDriver.test.ts new file mode 100644 index 00000000000..3b3f91aa9ef --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.test.ts @@ -0,0 +1,95 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderInstanceConfigMap, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeProviderInstanceRegistry } from "../Layers/ProviderInstanceRegistryLive.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { ProviderOrchestrationAdapterInfrastructureLive } from "../Layers/ProviderOrchestrationAdapterInfrastructure.ts"; +import { CopilotDriver } from "./CopilotDriver.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const BackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: true, + updatedAt: TEST_EPOCH, + }), + streamChanges: Stream.empty, + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), +}); +const TestHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "1.0.79" }))), + ), +); +const baseLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "copilot-driver-test", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(BackgroundPolicyAlwaysRun), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(TestHttpClient), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), +); +const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( + Layer.provideMerge(baseLayer), +); + +describe("CopilotDriver", () => { + it.effect("registers a disabled Copilot instance through the provider driver SPI", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("copilot"); + const configMap: ProviderInstanceConfigMap = { + [instanceId]: { + driver: ProviderDriverKind.make("copilot"), + enabled: false, + config: { + enabled: false, + binaryPath: "copilot", + customModels: [], + }, + }, + }; + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CopilotDriver], + configMap, + }); + const instance = yield* registry.getInstance(instanceId); + expect(instance?.driverKind).toBe("copilot"); + expect(instance?.orchestrationAdapter.driver).toBe("copilot"); + expect((yield* instance!.snapshot.getSnapshot).displayName).toBe("GitHub Copilot"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 00000000000..12ec75e2dde --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,174 @@ +import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { + CopilotAdapterV2Driver, + type CopilotAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/CopilotAdapterV2.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialCopilotProviderSnapshot, + checkCopilotProviderStatus, + enrichCopilotSnapshot, +} from "../Layers/CopilotProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); +const DRIVER_KIND = ProviderDriverKind.make("copilot"); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@github/copilot", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type CopilotDriverEnv = + | CopilotAdapterV2DriverEnv + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "GitHub Copilot", + supportsMultipleInstances: true, + }, + configSchema: CopilotSettings, + defaultConfig: (): CopilotSettings => decodeCopilotSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CopilotSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const orchestrationAdapter = yield* CopilotAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build GitHub Copilot orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv); + const checkProvider = checkCopilotProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialCopilotProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichCopilotSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build GitHub Copilot snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts new file mode 100644 index 00000000000..c70e771d82c --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { CopilotSettings } from "@t3tools/contracts"; +import { + buildCopilotDiscoveredModels, + buildCopilotModelCapabilities, + buildCopilotProviderModels, + buildInitialCopilotProviderSnapshot, + checkCopilotProviderStatus, + isCopilotAuthFailure, +} from "./CopilotProvider.ts"; + +const decodeSettings = Schema.decodeSync(CopilotSettings); + +describe("CopilotProvider helpers", () => { + it.effect("presents an enabled Early Access provider with in-session model changes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialCopilotProviderSnapshot(decodeSettings({})); + expect(snapshot.displayName).toBe("GitHub Copilot"); + expect(snapshot.badgeLabel).toBe("Early Access"); + expect(snapshot.showInteractionModeToggle).toBe(false); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + }), + ); + + it("discovers ACP models and exposes reasoning effort", () => { + const configOptions = [ + { + id: "reasoning_effort", + name: "Reasoning effort", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ] as const; + const capabilities = buildCopilotModelCapabilities(configOptions); + expect(capabilities.optionDescriptors?.[0]?.id).toBe("reasoningEffort"); + expect( + buildCopilotDiscoveredModels( + { + currentModelId: "gpt-5.4", + availableModels: [{ modelId: "gpt-5.4", name: "GPT-5.4" }], + }, + configOptions, + ), + ).toMatchObject([{ slug: "gpt-5.4", name: "GPT-5.4" }]); + }); + + it("preserves the auto fallback when ACP reports no models", () => { + expect(buildCopilotProviderModels([], [])).toMatchObject([ + { slug: "auto", name: "Auto", isCustom: false }, + ]); + }); + + it("recognizes auth failures without treating generic startup errors as logged out", () => { + expect(isCopilotAuthFailure(new Error("Not authenticated. Run copilot login."))).toBe(true); + expect(isCopilotAuthFailure({ cause: { message: "GH_TOKEN is invalid" } })).toBe(true); + expect(isCopilotAuthFailure(new Error("Unexpected token in JSON"))).toBe(false); + expect(isCopilotAuthFailure(new Error("ACP transport closed unexpectedly"))).toBe(false); + }); +}); + +it.layer(NodeServices.layer)("checkCopilotProviderStatus", (it) => { + it.effect("clearly reports a missing Copilot CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkCopilotProviderStatus( + decodeSettings({ + binaryPath: "/definitely/not/installed/copilot", + }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).toMatch(/not installed|not on PATH/); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts new file mode 100644 index 00000000000..7074d9515c3 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,357 @@ +import { + type CopilotSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { makeCopilotAcpRuntime, resolveCopilotModelId } from "../acp/CopilotAcpSupport.ts"; +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +const COPILOT_PRESENTATION = { + displayName: "GitHub Copilot", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const ACP_DISCOVERY_TIMEOUT_MS = 15_000; + +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildCopilotProviderModels( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = FALLBACK_MODELS, +): ReadonlyArray { + return providerModelsFromSettings( + builtInModels.length > 0 ? builtInModels : FALLBACK_MODELS, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +function selectOptions( + option: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray<{ value: string; label: string; isDefault?: boolean }> { + if (!option || option.type !== "select") return []; + return option.options.flatMap((entry) => + "value" in entry + ? [ + { + value: entry.value, + label: entry.name, + ...(entry.value === option.currentValue ? { isDefault: true } : {}), + }, + ] + : entry.options.map((nested) => ({ + value: nested.value, + label: nested.name, + ...(nested.value === option.currentValue ? { isDefault: true } : {}), + })), + ); +} + +export function buildCopilotModelCapabilities( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + const reasoning = configOptions?.find((option) => option.id === "reasoning_effort"); + const options = selectOptions(reasoning); + return createModelCapabilities({ + optionDescriptors: + options.length === 0 + ? [] + : [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: reasoning?.name.trim() || "Reasoning", + options, + }), + ], + }); +} + +export function buildCopilotDiscoveredModels( + modelState: EffectAcpSchema.SessionModelState | null | undefined, + configOptions?: ReadonlyArray, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) return []; + const capabilities = buildCopilotModelCapabilities(configOptions); + const seen = new Set(); + return modelState.availableModels.flatMap((model) => { + const slug = resolveCopilotModelId(model.modelId); + if (seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities, + } satisfies ServerProviderModel, + ]; + }); +} + +export function isCopilotAuthFailure(value: unknown): boolean { + const seen = new Set(); + const collect = (current: unknown, depth: number): string => { + if (depth > 6 || current === null || current === undefined || seen.has(current)) return ""; + seen.add(current); + if (typeof current === "string") return current; + if (current instanceof Error) return `${current.message} ${collect(current.cause, depth + 1)}`; + if (!Predicate.isObject(current)) return String(current); + return ["message", "detail", "errorMessage", "cause", "error", "data"] + .map((key) => collect(current[key], depth + 1)) + .join(" "); + }; + return /(?:not authenticated|unauthenticated|authentication|unauthorized|log(?:ged)? in|login|credential|(?:access|auth(?:entication)?|refresh|id|bearer|gh|github|copilot)[ _-]?token|gh auth)/i.test( + collect(value, 0), + ); +} + +export function buildInitialCopilotProviderSnapshot( + settings: CopilotSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = buildCopilotProviderModels(settings.customModels); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking GitHub Copilot CLI availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "GitHub Copilot is disabled in T3 Code settings.", + }, + }); + }); +} + +export const discoverCopilotModelsViaAcp = ( + settings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeCopilotAcpRuntime({ + copilotSettings: settings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildCopilotDiscoveredModels( + started.sessionSetupResult.models, + yield* acp.getConfigOptions, + ); + }).pipe(Effect.scoped); + +const runCopilotVersionCommand = ( + settings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "copilot"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version", "--no-auto-update"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkCopilotProviderStatus = Effect.fn("checkCopilotProviderStatus")(function* ( + settings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = buildCopilotProviderModels(settings.customModels); + if (!settings.enabled) return yield* buildInitialCopilotProviderSnapshot(settings); + + const versionResult = yield* runCopilotVersionCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + const missing = isCommandMissingCause(versionResult.failure); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "GitHub Copilot CLI (`copilot`) is not installed or not on PATH." + : "Failed to execute the GitHub Copilot CLI health check.", + }, + }); + } + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "GitHub Copilot CLI timed out while running `copilot --version`.", + }, + }); + } + + const output = versionResult.success.value; + const version = parseGenericCliVersion(`${output.stdout}\n${output.stderr}`); + if (output.code !== 0) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "GitHub Copilot CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverCopilotModelsViaAcp(settings, environment).pipe( + Effect.timeoutOption(ACP_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + const failure = Cause.squash(discoveryExit.cause); + const unauthenticated = isCopilotAuthFailure(failure); + yield* Effect.logWarning("GitHub Copilot ACP discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: unauthenticated ? "unauthenticated" : "unknown" }, + message: unauthenticated + ? "GitHub Copilot CLI is not authenticated. Run `copilot login` and try again." + : "GitHub Copilot ACP session startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `GitHub Copilot ACP session startup timed out after ${ACP_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + + const discoveredModels = discoveryExit.value.value; + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: buildCopilotProviderModels(settings.customModels, discoveredModels), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const enrichCopilotSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + Effect.catchCause((cause) => + Effect.logWarning("GitHub Copilot version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 65422bde7da..41c984e2170 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `copilot`, `cursor`, `grok`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `copilot` / `agent` / `grok` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -27,6 +27,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { type ClaudeSettings, type CodexSettings, + type CopilotSettings, type CursorSettings, type GrokSettings, type OpenCodeSettings, @@ -46,6 +47,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; +import { CopilotDriver } from "../Drivers/CopilotDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; @@ -117,6 +119,13 @@ const makeCursorConfig = (overrides: Partial): CursorSettings => ...overrides, }); +const makeCopilotConfig = (overrides: Partial): CopilotSettings => ({ + enabled: false, + binaryPath: "copilot", + customModels: [], + ...overrides, +}); + const makeGrokConfig = (overrides: Partial): GrokSettings => ({ enabled: false, binaryPath: "grok", @@ -146,7 +155,6 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( @@ -289,7 +297,6 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( @@ -301,12 +308,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); + const copilotId = ProviderInstanceId.make("copilot_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); + const copilotDriverKind = ProviderDriverKind.make("copilot"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); @@ -332,6 +341,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeCursorConfig({}), }, + [copilotId]: { + driver: copilotDriverKind, + displayName: "GitHub Copilot", + enabled: false, + config: makeCopilotConfig({}), + }, [grokId]: { driver: grokDriverKind, displayName: "Grok", @@ -347,7 +362,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [ + CodexDriver, + ClaudeDriver, + CopilotDriver, + CursorDriver, + GrokDriver, + OpenCodeDriver, + ], configMap, }); @@ -357,9 +379,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, copilotId, cursorId, grokId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -368,16 +390,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); + const copilot = yield* registry.getInstance(copilotId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(copilot?.driverKind).toBe(copilotDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); + expect(copilot?.displayName).toBe("GitHub Copilot"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); @@ -390,6 +415,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.orchestrationAdapter, claude!.orchestrationAdapter, cursor!.orchestrationAdapter, + copilot!.orchestrationAdapter, grok!.orchestrationAdapter, openCode!.orchestrationAdapter, ]; @@ -398,6 +424,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.textGeneration, claude!.textGeneration, cursor!.textGeneration, + copilot!.textGeneration, grok!.textGeneration, openCode!.textGeneration, ]; @@ -406,6 +433,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.snapshot, claude!.snapshot, cursor!.snapshot, + copilot!.snapshot, grok!.snapshot, openCode!.snapshot, ]; @@ -437,6 +465,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { `${cursorDriverKind}:instance:${cursorId}`, ); + const copilotSnapshot = yield* copilot!.snapshot.getSnapshot; + expect(copilotSnapshot.instanceId).toBe(copilotId); + expect(copilotSnapshot.driver).toBe(copilotDriverKind); + expect(copilotSnapshot.enabled).toBe(false); + expect(copilotSnapshot.continuation?.groupKey).toBe( + `${copilotDriverKind}:instance:${copilotId}`, + ); + const grokSnapshot = yield* grok!.snapshot.getSnapshot; expect(grokSnapshot.instanceId).toBe(grokId); expect(grokSnapshot.driver).toBe(grokDriverKind); diff --git a/apps/server/src/provider/acp/CopilotAcpSupport.test.ts b/apps/server/src/provider/acp/CopilotAcpSupport.test.ts new file mode 100644 index 00000000000..b350349d923 --- /dev/null +++ b/apps/server/src/provider/acp/CopilotAcpSupport.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + applyCopilotSessionConfiguration, + buildCopilotAcpSpawnInput, + COPILOT_AUTH_METHOD_ID, + resolveCopilotModeId, + resolveCopilotModelId, +} from "./CopilotAcpSupport.ts"; + +describe("CopilotAcpSupport", () => { + it("uses the authentication method advertised by the official CLI", () => { + expect(COPILOT_AUTH_METHOD_ID).toBe("copilot-login"); + }); + + it("builds the official Copilot ACP stdio launch and preserves environment", () => { + const spawn = buildCopilotAcpSpawnInput( + { binaryPath: "/usr/local/bin/copilot" }, + "/tmp/project", + { + COPILOT_GITHUB_TOKEN: "secret", + COPILOT_HOME: "/tmp/copilot-home", + }, + ); + expect(spawn).toEqual({ + command: "/usr/local/bin/copilot", + args: ["--acp", "--stdio", "--no-auto-update"], + cwd: "/tmp/project", + env: { + COPILOT_GITHUB_TOKEN: "secret", + COPILOT_HOME: "/tmp/copilot-home", + }, + }); + }); + + it("normalizes model ids and keeps Copilot in Agent mode", () => { + expect(resolveCopilotModelId(" gpt-5.4 ")).toBe("gpt-5.4"); + expect(resolveCopilotModelId(undefined)).toBe("auto"); + const modeState = { + currentModeId: "agent", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "plan", name: "Plan" }, + { id: "autopilot", name: "Autopilot" }, + ], + }; + expect( + resolveCopilotModeId({ + modeState, + interactionMode: "plan", + runtimeMode: "approval-required", + }), + ).toBe("agent"); + expect( + resolveCopilotModeId({ + modeState, + interactionMode: "default", + runtimeMode: "approval-required", + }), + ).toBe("agent"); + expect( + resolveCopilotModeId({ + modeState, + interactionMode: "default", + runtimeMode: "full-access", + }), + ).toBe("agent"); + }); + + it.effect("sets model and reasoning effort without mutating allow_all", () => + Effect.gen(function* () { + const calls: Array = []; + yield* applyCopilotSessionConfiguration({ + runtime: { + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + type: "select", + currentValue: "gpt-5", + options: [{ value: "gpt-5.4", name: "GPT-5.4" }], + }, + { + id: "reasoning_effort", + name: "Reasoning effort", + type: "select", + currentValue: "medium", + options: [{ value: "high", name: "High" }], + }, + { + id: "allow_all", + name: "Allow all", + type: "boolean", + currentValue: false, + }, + ]), + setModel: (model) => + Effect.sync(() => { + calls.push(["model", model]); + }), + setConfigOption: (id, value) => + Effect.sync(() => { + calls.push([id, value]); + return { configOptions: [] }; + }), + }, + model: "gpt-5.4", + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (context) => context.cause, + }); + expect(calls).toEqual([ + ["model", "gpt-5.4"], + ["reasoning_effort", "high"], + ]); + }), + ); + + it.effect("resets a resumed concrete model to the negotiated automatic model", () => + Effect.gen(function* () { + const models: Array = []; + yield* applyCopilotSessionConfiguration({ + runtime: { + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + type: "select", + currentValue: "gpt-5.4", + options: [ + { value: "default", name: "Auto" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + ], + }, + ]), + setModel: (model) => + Effect.sync(() => { + models.push(model); + }), + setConfigOption: () => Effect.succeed({ configOptions: [] }), + }, + model: "auto", + selections: [], + mapError: (context) => context.cause, + }); + expect(models).toEqual(["default"]); + }), + ); + + it.effect("refreshes model-dependent options before applying reasoning effort", () => + Effect.gen(function* () { + const calls: Array = []; + let modelConfigured = false; + yield* applyCopilotSessionConfiguration({ + runtime: { + getConfigOptions: Effect.sync(() => + modelConfigured + ? [ + { + id: "reasoning_effort", + name: "Reasoning effort", + type: "select" as const, + currentValue: "medium", + options: [{ value: "high", name: "High" }], + }, + ] + : [ + { + id: "model", + name: "Model", + type: "select" as const, + currentValue: "default", + options: [{ value: "gpt-5.4", name: "GPT-5.4" }], + }, + ], + ), + setModel: (model) => + Effect.sync(() => { + calls.push(["model", model]); + modelConfigured = true; + }), + setConfigOption: (id, value) => + Effect.sync(() => { + calls.push([id, String(value)]); + return { configOptions: [] }; + }), + }, + model: "gpt-5.4", + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (context) => context.cause, + }); + expect(calls).toEqual([ + ["model", "gpt-5.4"], + ["reasoning_effort", "high"], + ]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/CopilotAcpSupport.ts b/apps/server/src/provider/acp/CopilotAcpSupport.ts new file mode 100644 index 00000000000..17816e4fc82 --- /dev/null +++ b/apps/server/src/provider/acp/CopilotAcpSupport.ts @@ -0,0 +1,176 @@ +import { + type CopilotSettings, + type ProviderInteractionMode, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; + +export const COPILOT_AUTH_METHOD_ID = "copilot-login"; +const DEFAULT_COPILOT_MODEL = "auto"; + +type CopilotAcpRuntimeSettings = Pick; + +export interface CopilotAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly copilotSettings: CopilotAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildCopilotAcpSpawnInput( + copilotSettings: CopilotAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: copilotSettings?.binaryPath || "copilot", + args: ["--acp", "--stdio", "--no-auto-update"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeCopilotAcpRuntime = ( + input: CopilotAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildCopilotAcpSpawnInput(input.copilotSettings, input.cwd, input.environment), + authMethodId: COPILOT_AUTH_METHOD_ID, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveCopilotModelId(model: string | null | undefined): string { + return model?.trim() || DEFAULT_COPILOT_MODEL; +} + +export function currentCopilotModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +function selectedString( + selections: ReadonlyArray | null | undefined, + id: string, +): string | undefined { + const value = selections?.find((selection) => selection.id === id)?.value; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function hasConfigOption( + options: ReadonlyArray, + id: string, +): boolean { + return options.some((option) => option.id === id); +} + +function resolveCopilotSessionModelId( + requestedModel: string, + options: ReadonlyArray, +): string { + const model = resolveCopilotModelId(requestedModel); + if (model !== "auto" && model !== "default") return model; + + const modelOption = options.find((option) => option.id === "model"); + if (!modelOption || modelOption.type !== "select") return model; + const choices = modelOption.options.flatMap((option) => + "value" in option ? [option] : option.options, + ); + const aliases = model === "auto" ? ["auto", "default"] : ["default", "auto"]; + return ( + aliases.flatMap((alias) => choices.filter((choice) => choice.value === alias))[0]?.value ?? + choices.find((choice) => /^(?:auto(?:matic)?|default)$/i.test(choice.name.trim()))?.value ?? + model + ); +} + +export function applyCopilotSessionConfiguration(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" | "setModel" + >; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + readonly method: "session/set_config_option"; + readonly configId: string; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + let configOptions = yield* input.runtime.getConfigOptions; + if (input.model?.trim()) { + const model = resolveCopilotSessionModelId(input.model, configOptions); + yield* input.runtime + .setModel(model) + .pipe( + Effect.mapError((cause) => + input.mapError({ cause, method: "session/set_config_option", configId: "model" }), + ), + ); + configOptions = yield* input.runtime.getConfigOptions; + } + + const reasoningEffort = selectedString(input.selections, "reasoningEffort"); + if (reasoningEffort && hasConfigOption(configOptions, "reasoning_effort")) { + yield* input.runtime.setConfigOption("reasoning_effort", reasoningEffort).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_config_option", + configId: "reasoning_effort", + }), + ), + ); + } + }); +} + +function findMode( + modeState: AcpSessionModeState | undefined, + names: ReadonlyArray, +): string | undefined { + if (!modeState) return undefined; + const aliases = new Set(names.map((name) => name.toLowerCase())); + return modeState.availableModes.find( + (mode) => aliases.has(mode.id.toLowerCase()) || aliases.has(mode.name.toLowerCase()), + )?.id; +} + +export function resolveCopilotModeId(input: { + readonly modeState: AcpSessionModeState | undefined; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; +}): string | undefined { + return findMode(input.modeState, ["agent"]) ?? input.modeState?.currentModeId; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2..fbc3328dea7 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | AcpRegistryDriverEnv | ClaudeDriverEnv | CodexDriverEnv + | CopilotDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -49,6 +51,7 @@ export type BuiltInDriversEnv = export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, ClaudeDriver, + CopilotDriver, CursorDriver, GrokDriver, OpenCodeDriver, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts new file mode 100644 index 00000000000..ad72a400b7c --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -0,0 +1,144 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { expect } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import { makeCopilotTextGeneration } from "./CopilotTextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; + +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const CopilotTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeAcpCopilotWrapper(dir: string, env: Record): string { + const binDir = NodePath.join(dir, "bin"); + const copilotPath = NodePath.join(binDir, "copilot"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( + copilotPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "--acp" ] || [ "$2" != "--stdio" ] || [ "$3" != "--no-auto-update" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(copilotPath, 0o755); + return copilotPath; +} + +function withFakeAcpCopilot( + env: Record, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-copilot-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const binaryPath = makeAcpCopilotWrapper(tempDir, env); + const config = decodeCopilotSettings({ binaryPath }); + const textGeneration = yield* makeCopilotTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function readJsonRpcRequests( + filePath: string, +): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { + return NodeFS.readFileSync(filePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: Record }); +} + +it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { + it.effect("uses Copilot ACP for structured commit messages", () => { + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-copilot-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpCopilot( + { + T3_ACP_AUTH_METHOD_ID: "copilot-login", + T3_ACP_INITIAL_MODEL_ID: "gpt-5.4", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add GitHub Copilot provider", + body: "Run Copilot through the shared ACP runtime.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot", + stagedSummary: "M apps/server/src/provider/Drivers/CopilotDriver.ts", + stagedPatch: "diff --git a/.../CopilotDriver.ts b/.../CopilotDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("copilot"), "auto"), + }); + + expect(generated).toEqual({ + subject: "Add GitHub Copilot provider", + body: "Run Copilot through the shared ACP runtime.", + }); + const requests = readJsonRpcRequests(requestLogPath); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "default", + ), + ).toBe(true); + }), + ); + }); + + it.effect("extracts structured output from conversational text", () => + withFakeAcpCopilot( + { + T3_ACP_AUTH_METHOD_ID: "copilot-login", + T3_ACP_PROMPT_RESPONSE_TEXT: + "Here is the title:\n" + JSON.stringify({ title: "Investigate Copilot ACP" }) + "\nDone.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "the copilot provider needs a title", + modelSelection: createModelSelection(ProviderInstanceId.make("copilot"), "auto"), + }); + expect(generated.title).toBe("Investigate Copilot ACP"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts new file mode 100644 index 00000000000..b3910b90082 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -0,0 +1,263 @@ +import { type CopilotSettings, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + applyCopilotSessionConfiguration, + makeCopilotAcpRuntime, + resolveCopilotModeId, +} from "../provider/acp/CopilotAcpSupport.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const COPILOT_TIMEOUT_MS = 180_000; +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")(function* ( + copilotSettings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runCopilotJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + if (!copilotSettings.enabled) { + return yield* new TextGenerationError({ + operation, + detail: "GitHub Copilot is disabled in T3 Code settings.", + }); + } + + const outputRef = yield* Ref.make(""); + const runtime = yield* makeCopilotAcpRuntime({ + copilotSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* applyCopilotSessionConfiguration({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause }) => + new TextGenerationError({ + operation, + detail: "Failed to configure GitHub Copilot ACP text generation.", + cause, + }), + }); + const modeId = resolveCopilotModeId({ + modeState: yield* runtime.getModeState, + interactionMode: "default", + runtimeMode: "approval-required", + }); + if (modeId) { + yield* runtime.setMode(modeId); + } + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(COPILOT_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "GitHub Copilot ACP request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "GitHub Copilot ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "GitHub Copilot ACP request was cancelled." + : "GitHub Copilot returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "GitHub Copilot returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "GitHub Copilot ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CopilotTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CopilotTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CopilotTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CopilotTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f..da05be4fb96 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "copilot" + | "cursor" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1f..cc31f89c253 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,10 +1,19 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GithubCopilotIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, + [ProviderDriverKind.make("copilot")]: GithubCopilotIcon, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 260d895ff2b..4ac53f586bf 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,7 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, PiAgentIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -78,11 +78,6 @@ interface ComingSoonDriverOption { } const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ - { - value: ProviderDriverKind.make("githubCopilot"), - label: "Github Copilot", - icon: GithubCopilotIcon, - }, { value: ProviderDriverKind.make("gemini"), label: "Gemini", diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 45ae5a79921..234acff6995 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,6 +2,7 @@ import { AcpRegistrySettings, ClaudeSettings, CodexSettings, + CopilotSettings, CursorSettings, GrokSettings, OpenCodeSettings, @@ -12,6 +13,7 @@ import { ACPRegistryIcon, ClaudeAI, CursorIcon, + GithubCopilotIcon, GrokIcon, type Icon, OpenAI, @@ -67,6 +69,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: ClaudeAI, settingsSchema: ClaudeSettings, }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + icon: GithubCopilotIcon, + badgeLabel: "Early Access", + settingsSchema: CopilotSettings, + }, { value: ProviderDriverKind.make("cursor"), label: "Cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a12b2db048b..5ffb7faa740 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -31,6 +31,12 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + available: true, + pickerSidebarBadge: "new", + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/docs/README.md b/docs/README.md index adaebd5bee0..17435ab3fd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,8 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · + [GitHub Copilot](./user/providers-copilot.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03d..4b9f7b50785 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,15 +7,17 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: - -| Driver kind | Driver source | -| ------------- | --------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with seven entries: + +| Driver kind | Driver source | +| ------------- | ---------------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `copilot` | [`Drivers/CopilotDriver.ts`][copilot] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `acpRegistry` | [`Drivers/AcpRegistryDriver.ts`][acp-registry] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -78,9 +80,11 @@ when a request opens (approval) or user input is requested, via [drivers]: ../../apps/server/src/provider/builtInDrivers.ts [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts +[copilot]: ../../apps/server/src/provider/Drivers/CopilotDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[acp-registry]: ../../apps/server/src/provider/Drivers/AcpRegistryDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/providers-copilot.md b/docs/user/providers-copilot.md new file mode 100644 index 00000000000..77d75b5b698 --- /dev/null +++ b/docs/user/providers-copilot.md @@ -0,0 +1,40 @@ +# GitHub Copilot + +T3 Code connects directly to the official GitHub Copilot CLI through its ACP server. It does not +route Copilot through OpenCode. + +## Install + +Install the CLI globally: + +```bash +npm install -g @github/copilot +``` + +Then authenticate: + +```bash +copilot login +``` + +Keep the provider's Binary path set to `copilot` unless the executable is installed elsewhere. + +## Token Authentication + +The Copilot CLI also accepts authentication from environment variables. Add one of these to the +Copilot provider instance in Settings: + +```text +COPILOT_GITHUB_TOKEN +GH_TOKEN +GITHUB_TOKEN +``` + +Mark tokens as sensitive. The CLI decides credential precedence and can also use a stored +`copilot login` or GitHub CLI (`gh auth`) session. T3 Code forwards the provider environment, +including `COPILOT_HOME`, without interpreting credentials. + +## Early Access + +GitHub currently describes ACP support as a public preview. T3 Code labels the provider Early +Access because CLI behavior may change while that preview evolves. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1c8c16a0638..226b5d3248c 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const COPILOT_DRIVER_KIND = ProviderDriverKind.make("copilot"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [COPILOT_DRIVER_KIND]: "GitHub Copilot", [GROK_DRIVER_KIND]: "Grok", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [OPENCODE_DRIVER_KIND]: "OpenCode", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 34f82d10fa7..80445c25ec0 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -131,6 +131,11 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.copilot).toEqual({ + enabled: true, + binaryPath: "copilot", + customModels: [], + }); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { @@ -293,6 +298,9 @@ describe("ServerSettingsPatch string normalization", () => { homePath: " ~/.codex ", launchArgs: " --strict-config --enable foo ", }, + copilot: { + binaryPath: " /opt/homebrew/bin/copilot ", + }, }, providerInstances: { codex_personal: { @@ -309,6 +317,7 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); + expect(patch.providers?.copilot?.binaryPath).toBe("/opt/homebrew/bin/copilot"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3572a241514..d68415ff686 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -456,6 +456,30 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; +export const CopilotSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("copilot").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the GitHub Copilot CLI binary.", + providerSettingsForm: { placeholder: "copilot", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type CopilotSettings = typeof CopilotSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -633,6 +657,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -729,6 +754,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CopilotSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -774,6 +805,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + copilot: Schema.optionalKey(CopilotSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch),