diff --git a/apps/server/package.json b/apps/server/package.json index ea818b7d3..78d0664e2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.10", + "@github/copilot-sdk": "0.2.0", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/git/Layers/CopilotTextGeneration.test.ts b/apps/server/src/git/Layers/CopilotTextGeneration.test.ts new file mode 100644 index 000000000..ac2f947c2 --- /dev/null +++ b/apps/server/src/git/Layers/CopilotTextGeneration.test.ts @@ -0,0 +1,238 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import type { ModelInfo, SessionEvent } from "@github/copilot-sdk"; +import { Effect, Layer } from "effect"; +import { expect, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { makeCopilotTextGenerationLive } from "./CopilotTextGeneration.ts"; + +class FakeCopilotSession { + public readonly sendImpl = vi.fn( + async (_input: { + prompt: string; + attachments?: Array<{ type: "file"; path: string; displayName?: string }>; + mode?: "enqueue" | "immediate"; + }) => { + this.onEvent?.({ + id: "turn-start", + timestamp: new Date().toISOString(), + parentId: null, + type: "assistant.turn_start", + data: { turnId: "turn-1" }, + }); + this.onEvent?.({ + id: "assistant-message", + timestamp: new Date().toISOString(), + parentId: "turn-start", + type: "assistant.message", + data: { + messageId: "message-1", + content: this.messageContent, + }, + }); + this.onEvent?.({ + id: "turn-end", + timestamp: new Date().toISOString(), + parentId: "assistant-message", + type: "assistant.turn_end", + data: { turnId: "turn-1" }, + }); + return "message-1"; + }, + ); + public readonly getMessagesImpl = vi.fn( + async (): Promise> => [ + { + id: "assistant-message", + timestamp: new Date().toISOString(), + parentId: null, + type: "assistant.message", + data: { + messageId: "message-1", + content: this.messageContent, + }, + }, + ], + ); + public readonly destroyImpl = vi.fn(async () => undefined); + public onEvent: ((event: SessionEvent) => void) | undefined; + + constructor(public messageContent: string) {} + + send(input: { + prompt: string; + attachments?: Array<{ type: "file"; path: string; displayName?: string }>; + mode?: "enqueue" | "immediate"; + }) { + return this.sendImpl(input); + } + + getMessages() { + return this.getMessagesImpl(); + } + + destroy() { + return this.destroyImpl(); + } +} + +class FakeCopilotClient { + public readonly startImpl = vi.fn(async () => undefined); + public readonly stopImpl = vi.fn(async () => [] as Error[]); + public readonly listModelsImpl = vi.fn(async (): Promise> => []); + public readonly createSessionImpl = vi.fn( + async ( + config: { onEvent?: ((event: SessionEvent) => void) | undefined } & Record, + ) => { + this.session.onEvent = config.onEvent; + return this.session; + }, + ); + + constructor(public readonly session: FakeCopilotSession) {} + + start() { + return this.startImpl(); + } + + stop() { + return this.stopImpl(); + } + + listModels() { + return this.listModelsImpl(); + } + + createSession( + config: { onEvent?: ((event: SessionEvent) => void) | undefined } & Record, + ) { + return this.createSessionImpl(config); + } +} + +function makeModelInfo(input: { + id: string; + name: string; + supportedReasoningEfforts?: ReadonlyArray<"low" | "medium" | "high" | "xhigh">; +}) { + return input as unknown as import("@github/copilot-sdk").ModelInfo; +} + +const session = new FakeCopilotSession( + JSON.stringify({ + subject: " Add Copilot text generation. ", + body: "- updated settings\n- added routing", + }), +); +const client = new FakeCopilotClient(session); +let lastClientFactoryOptions: unknown; + +const CopilotTextGenerationTestLayer = makeCopilotTextGenerationLive({ + clientFactory: (options) => { + lastClientFactoryOptions = options; + return client; + }, +}).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(CopilotTextGenerationTestLayer)("CopilotTextGenerationLive", (it) => { + it.effect("generates and sanitizes commit messages", () => + Effect.gen(function* () { + client.listModelsImpl.mockReset(); + client.createSessionImpl.mockClear(); + session.sendImpl.mockClear(); + lastClientFactoryOptions = undefined; + client.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }), + ]); + + const textGeneration = yield* TextGeneration; + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-text-generation", + stagedSummary: "M apps/server/src/git/Layers/CopilotTextGeneration.ts", + stagedPatch: "diff --git a/file b/file", + modelSelection: { + provider: "copilot", + model: "gpt-5.4-mini", + }, + }); + + expect(generated.subject).toBe("Add Copilot text generation"); + expect(generated.body).toBe("- updated settings\n- added routing"); + const sessionConfig = client.createSessionImpl.mock.calls[0]?.[0] as Record; + expect(sessionConfig.model).toBe("gpt-5.4-mini"); + expect(sessionConfig.reasoningEffort).toBe("low"); + expect(sessionConfig.workingDirectory).toBe(process.cwd()); + expect(lastClientFactoryOptions).toMatchObject({ + cwd: process.cwd(), + logLevel: "error", + }); + expect(session.sendImpl.mock.calls[0]?.[0]).toMatchObject({ + mode: "immediate", + }); + }), + ); + + it.effect("uses configured binary path and config dir for Copilot text generation", () => + Effect.gen(function* () { + client.listModelsImpl.mockReset(); + client.createSessionImpl.mockClear(); + client.startImpl.mockClear(); + lastClientFactoryOptions = undefined; + client.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }), + ]); + + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.updateSettings({ + providers: { + copilot: { + binaryPath: "/tmp/copilot", + configDir: "/tmp/copilot-config", + }, + }, + }); + + const textGeneration = yield* TextGeneration; + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: null, + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "copilot", + model: "gpt-5.4", + options: { reasoningEffort: "high" }, + }, + }); + + const sessionConfig = client.createSessionImpl.mock.calls[0]?.[0] as Record; + expect(lastClientFactoryOptions).toMatchObject({ + cliPath: "/tmp/copilot", + cwd: process.cwd(), + logLevel: "error", + }); + expect(sessionConfig.configDir).toBe("/tmp/copilot-config"); + expect(sessionConfig.reasoningEffort).toBe("high"); + }), + ); +}); diff --git a/apps/server/src/git/Layers/CopilotTextGeneration.ts b/apps/server/src/git/Layers/CopilotTextGeneration.ts new file mode 100644 index 000000000..00d670c92 --- /dev/null +++ b/apps/server/src/git/Layers/CopilotTextGeneration.ts @@ -0,0 +1,433 @@ +import { Effect, Layer, Schema } from "effect"; + +import { + type CodexReasoningEffort, + type CopilotModelSelection, + type ChatAttachment, +} from "@t3tools/contracts"; +import { + approveAll, + CopilotClient, + type CopilotClientOptions, + type SessionEvent, +} from "@github/copilot-sdk"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { ServerConfig } from "../../config.ts"; +import { + loadCopilotSupportedModels, + materializeCopilotAttachments, + resolveCopilotRuntimeConfig, + resolveCopilotSelectedModel, + selectCopilotReasoningEffort, + stopCopilotClient, + validateCopilotReasoningEffort, +} from "../../provider/Layers/copilotSdk.ts"; +import { TextGenerationError } from "../Errors.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "../Prompts.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "../Utils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const COPILOT_GIT_TEXT_GENERATION_REASONING_EFFORT = "low" as const; +const COPILOT_TIMEOUT_MS = 180_000; + +export interface CopilotTextGenerationLiveOptions { + readonly clientFactory?: (options: CopilotClientOptions) => CopilotTextGenerationClientHandle; +} + +interface CopilotTextGenerationSessionHandle { + send(options: { + prompt: string; + attachments?: Array<{ type: "file"; path: string; displayName?: string }>; + mode?: "enqueue" | "immediate"; + }): Promise; + getMessages(): Promise>; + destroy(): Promise; +} + +interface CopilotTextGenerationClientHandle { + start(): Promise; + stop(): Promise>; + listModels(): Promise>; + createSession(config: { + model?: string; + reasoningEffort?: CodexReasoningEffort; + workingDirectory?: string; + configDir?: string; + streaming?: boolean; + onEvent?: (event: SessionEvent) => void; + onPermissionRequest?: unknown; + }): Promise; +} + +function buildStrictJsonPrompt(prompt: string): string { + return `${prompt}\n\nReturn only valid JSON. Do not include markdown fences or explanatory text.`; +} + +function findLastAssistantMessage(events: ReadonlyArray): string | null { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "assistant.message") { + const content = event.data.content.trim(); + if (content.length > 0) { + return content; + } + } + } + return null; +} + +function extractJsonCandidates(content: string): string[] { + const trimmed = content.trim(); + const candidates = new Set(); + if (trimmed.length > 0) { + candidates.add(trimmed); + } + + const fencedMatch = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); + if (fencedMatch?.[1]) { + candidates.add(fencedMatch[1].trim()); + } + + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.add(trimmed.slice(firstBrace, lastBrace + 1)); + } + + return [...candidates]; +} + +function decodeStructuredOutput< + S extends Schema.Top & { + readonly DecodingServices: never; + }, +>(input: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + content: string; + outputSchema: S; +}): Effect.Effect { + const decode = Schema.decodeUnknownSync(input.outputSchema); + + return Effect.try({ + try: () => { + for (const candidate of extractJsonCandidates(input.content)) { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + + try { + return decode(parsed); + } catch { + continue; + } + } + + throw new TextGenerationError({ + operation: input.operation, + detail: "GitHub Copilot returned invalid structured output.", + }); + }, + catch: (cause) => + Schema.is(TextGenerationError)(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "GitHub Copilot returned invalid structured output.", + cause, + }), + }); +} + +const makeCopilotTextGeneration = (options?: CopilotTextGenerationLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* Effect.service(ServerSettingsService); + + const runCopilotJson = < + S extends Schema.Top & { + readonly DecodingServices: never; + }, + >(input: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchema: S; + modelSelection: CopilotModelSelection; + attachments?: ReadonlyArray; + }): Effect.Effect => + Effect.gen(function* () { + const copilotSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.copilot), + Effect.mapError((cause) => + normalizeCliError( + "copilot", + input.operation, + cause, + "Failed to load GitHub Copilot settings", + ), + ), + ); + const { clientOptions, configDir } = resolveCopilotRuntimeConfig( + copilotSettings, + input.cwd, + ); + return yield* Effect.acquireUseRelease( + Effect.sync( + () => options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions), + ), + (client) => + Effect.gen(function* () { + const supportedModels = yield* loadCopilotSupportedModels({ + client, + onStartError: (cause) => + normalizeCliError( + "copilot", + input.operation, + cause, + "Failed to start GitHub Copilot client", + ), + onListError: (cause) => + normalizeCliError( + "copilot", + input.operation, + cause, + "Failed to load GitHub Copilot model metadata", + ), + }); + const selectedModel = yield* resolveCopilotSelectedModel({ + supportedModels, + model: input.modelSelection.model, + onMissingModel: (model) => + new TextGenerationError({ + operation: input.operation, + detail: `GitHub Copilot model '${model}' is not available in the current Copilot runtime.`, + }), + }); + + const explicitReasoningEffort = input.modelSelection.options?.reasoningEffort; + yield* validateCopilotReasoningEffort({ + selectedModel, + reasoningEffort: explicitReasoningEffort, + onMissingModel: () => + new TextGenerationError({ + operation: input.operation, + detail: + "GitHub Copilot reasoning effort requires an explicit supported model selection.", + }), + onUnsupportedModel: (modelId) => + new TextGenerationError({ + operation: input.operation, + detail: `GitHub Copilot model '${modelId}' does not support reasoning effort configuration.`, + }), + onUnsupportedReasoningEffort: (modelId, effort) => + new TextGenerationError({ + operation: input.operation, + detail: `GitHub Copilot model '${modelId}' does not support reasoning effort '${effort}'.`, + }), + }); + if (!selectedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: + "GitHub Copilot reasoning effort requires an explicit supported model selection.", + }); + } + const effectiveReasoningEffort = selectCopilotReasoningEffort({ + selectedModel, + explicitReasoningEffort, + fallbackReasoningEffort: COPILOT_GIT_TEXT_GENERATION_REASONING_EFFORT, + }); + + const attachments = materializeCopilotAttachments( + serverConfig.attachmentsDir, + input.attachments, + ); + const rawOutput = yield* Effect.tryPromise({ + try: async () => { + let activeTurnStarted = false; + let latestAssistantMessage: string | null = null; + let resolveTurnEnd: (() => void) | undefined; + const turnEnded = new Promise((resolve) => { + resolveTurnEnd = resolve; + }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: input.modelSelection.model, + ...(effectiveReasoningEffort + ? { reasoningEffort: effectiveReasoningEffort } + : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: false, + onEvent: (event) => { + if (event.type === "assistant.turn_start") { + activeTurnStarted = true; + return; + } + if (event.type === "assistant.message" && activeTurnStarted) { + latestAssistantMessage = event.data.content; + return; + } + if (event.type === "assistant.turn_end" && activeTurnStarted) { + resolveTurnEnd?.(); + } + }, + }); + + try { + await session.send({ + prompt: buildStrictJsonPrompt(input.prompt), + ...(attachments.length > 0 ? { attachments } : {}), + mode: "immediate", + }); + await Promise.race([ + turnEnded, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("GitHub Copilot request timed out.")); + }, COPILOT_TIMEOUT_MS); + }), + ]); + + if (!latestAssistantMessage) { + latestAssistantMessage = findLastAssistantMessage( + await session.getMessages(), + ); + } + if (!latestAssistantMessage || latestAssistantMessage.trim().length === 0) { + throw new Error("GitHub Copilot returned an empty response."); + } + return latestAssistantMessage; + } finally { + await session.destroy().catch(() => undefined); + } + }, + catch: (cause) => + normalizeCliError( + "copilot", + input.operation, + cause, + "GitHub Copilot request failed", + ), + }); + + return yield* decodeStructuredOutput({ + operation: input.operation, + content: rawOutput, + outputSchema: input.outputSchema, + }); + }), + (client) => stopCopilotClient(client), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CopilotTextGeneration.generateCommitMessage", + )(function* (input) { + if (input.modelSelection.provider !== "copilot") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + 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: TextGenerationShape["generatePrContent"] = Effect.fn( + "CopilotTextGeneration.generatePrContent", + )(function* (input) { + if (input.modelSelection.provider !== "copilot") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CopilotTextGeneration.generateBranchName", + )(function* (input) { + if (input.modelSelection.provider !== "copilot") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + ...(input.attachments ? { attachments: input.attachments } : {}), + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + } satisfies TextGenerationShape; + }); + +export const CopilotTextGenerationLive = Layer.effect(TextGeneration, makeCopilotTextGeneration()); + +export function makeCopilotTextGenerationLive(options?: CopilotTextGenerationLiveOptions) { + return Layer.effect(TextGeneration, makeCopilotTextGeneration(options)); +} diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 6fd86e1d5..02b3af0a7 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { GitActionProgressEvent, GitActionProgressPhase, ModelSelection } from "@t3tools/contracts"; +import { + GitActionProgressEvent, + GitActionProgressPhase, + type ModelSelection, +} from "@t3tools/contracts"; import { resolveAutoFeatureBranchName, sanitizeBranchFragment, diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 791513138..df503dc92 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { CopilotTextGenerationLive } from "./CopilotTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/CopilotTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,10 @@ class ClaudeTextGen extends ServiceMap.Service - provider === "claudeAgent" ? claude : codex; + provider === "claudeAgent" ? claude : provider === "copilot" ? copilot : codex; return { generateCommitMessage: (input) => @@ -66,7 +72,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalCopilotLayer = Layer.effect( + CopilotTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(CopilotTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalCopilotLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e9f2230f4..e917a29a0 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "copilot"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts new file mode 100644 index 000000000..daaf0486c --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,959 @@ +import assert from "node:assert/strict"; + +import { ThreadId } from "@t3tools/contracts"; +import { type SessionEvent } from "@github/copilot-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterAll, it, vi } from "@effect/vitest"; +import { beforeEach } from "vitest"; + +import { Effect, Fiber, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); + +class FakeCopilotSession { + public readonly sessionId: string; + public readonly modelSwitchToImpl = vi.fn( + async ({ modelId }: { modelId: string; reasoningEffort?: string }) => ({ + modelId, + }), + ); + + public readonly modeSetImpl = vi.fn( + async ({ mode }: { mode: "interactive" | "plan" | "autopilot" }) => ({ + mode, + }), + ); + + public readonly planReadImpl = vi.fn( + async (): Promise<{ + exists: boolean; + content: string | null; + path: string | null; + }> => ({ + exists: false, + content: null, + path: null, + }), + ); + + public readonly sendImpl = vi.fn( + async (_options: { prompt: string; attachments?: unknown; mode?: string }) => "message-1", + ); + + public readonly abortImpl = vi.fn(async () => undefined); + public readonly disconnectImpl = vi.fn(async () => undefined); + public readonly destroyImpl = vi.fn(async () => undefined); + public readonly getMessagesImpl = vi.fn(async () => [] as SessionEvent[]); + + private readonly handlers = new Set<(event: SessionEvent) => void>(); + + public readonly rpc = { + model: { + switchTo: this.modelSwitchToImpl, + }, + mode: { + set: this.modeSetImpl, + }, + plan: { + read: this.planReadImpl, + }, + }; + + constructor(sessionId: string) { + this.sessionId = sessionId; + } + + on(handler: (event: SessionEvent) => void) { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + send(options: { prompt: string; attachments?: unknown; mode?: string }) { + return this.sendImpl(options); + } + + abort() { + return this.abortImpl(); + } + + disconnect() { + return this.disconnectImpl(); + } + + destroy() { + return this.destroyImpl(); + } + + getMessages() { + return this.getMessagesImpl(); + } + + emit(event: SessionEvent) { + for (const handler of this.handlers) { + handler(event); + } + } +} + +class FakeCopilotClient { + public readonly startImpl = vi.fn(async () => undefined); + public readonly listModelsImpl = vi.fn(async () => []); + public readonly createSessionImpl = vi.fn(async (_config: unknown) => this.session); + public readonly resumeSessionImpl = vi.fn( + async (_sessionId: string, _config: unknown) => this.session, + ); + public readonly stopImpl = vi.fn(async () => [] as Error[]); + + constructor(private readonly session: FakeCopilotSession) {} + + start() { + return this.startImpl(); + } + + listModels() { + return this.listModelsImpl(); + } + + createSession(config: unknown) { + return this.createSessionImpl(config); + } + + resumeSession(sessionId: string, config: unknown) { + return this.resumeSessionImpl(sessionId, config); + } + + stop() { + return this.stopImpl(); + } +} + +function makeModelInfo(input: { + id: string; + name: string; + supportedReasoningEfforts?: ReadonlyArray<"low" | "medium" | "high" | "xhigh">; + defaultReasoningEffort?: "low" | "medium" | "high" | "xhigh"; +}) { + return input as unknown as import("@github/copilot-sdk").ModelInfo; +} + +function makeCopilotModelSelection( + model: string, + reasoningEffort?: "low" | "medium" | "high" | "xhigh", +) { + return { + provider: "copilot" as const, + model, + ...(reasoningEffort ? { options: { reasoningEffort } } : {}), + }; +} + +function diffDetailedContent(path: string) { + return [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); +} + +const modeSession = new FakeCopilotSession("copilot-session-mode"); +const modeClient = new FakeCopilotClient(modeSession); +const modeLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => modeClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +modeLayer("CopilotAdapterLive interaction mode", (it) => { + it.effect("switches the Copilot session mode when interactionMode changes", () => + Effect.gen(function* () { + modeSession.modeSetImpl.mockClear(); + modeSession.sendImpl.mockClear(); + + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-mode"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Plan the work", + interactionMode: "plan", + attachments: [], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Now execute it", + interactionMode: "default", + attachments: [], + }); + + assert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ + [{ mode: "plan" }], + [{ mode: "interactive" }], + ]); + assert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "enqueue"); + assert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "enqueue"); + }), + ); +}); + +const planSession = new FakeCopilotSession("copilot-session-plan"); +const planClient = new FakeCopilotClient(planSession); +const planLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => planClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +planLayer("CopilotAdapterLive proposed plan events", (it) => { + it.effect("emits a proposed-plan completion event from Copilot plan updates", () => + Effect.gen(function* () { + planSession.modeSetImpl.mockClear(); + planSession.planReadImpl.mockReset(); + planSession.planReadImpl.mockResolvedValue({ + exists: true, + content: "# Ship it\n\n- first\n- second", + path: "/tmp/copilot-session-plan/plan.md", + }); + + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-plan"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Draft a plan", + interactionMode: "plan", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + planSession.emit({ + id: "evt-plan-changed", + timestamp: new Date().toISOString(), + parentId: null, + type: "session.plan_changed", + data: { + operation: "update", + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.equal(events[0]?.type, "turn.plan.updated"); + if (events[0]?.type === "turn.plan.updated") { + assert.equal(events[0].turnId, turn.turnId); + assert.equal(events[0].payload.explanation, "Plan updated"); + } + + assert.equal(events[1]?.type, "turn.proposed.completed"); + if (events[1]?.type === "turn.proposed.completed") { + assert.equal(events[1].turnId, turn.turnId); + assert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); + } + }), + ); +}); + +const reasoningSession = new FakeCopilotSession("copilot-session-reasoning"); +const reasoningClient = new FakeCopilotClient(reasoningSession); +const reasoningLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => reasoningClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +reasoningLayer("CopilotAdapterLive reasoning", (it) => { + it.effect("passes reasoning effort when starting a session", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + }), + ] as never); + + const adapter = yield* CopilotAdapter; + yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-start"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "high"), + runtimeMode: "full-access", + }); + + assert.equal(reasoningClient.startImpl.mock.calls.length, 1); + assert.equal(reasoningClient.listModelsImpl.mock.calls.length, 1); + const createdConfig = reasoningClient.createSessionImpl.mock.calls[0]?.[0] as Record< + string, + unknown + >; + assert.equal(createdConfig.model, "gpt-5.4"); + assert.equal(createdConfig.reasoningEffort, "high"); + assert.equal(createdConfig.sessionId, "t3code-copilot-thread-reasoning-start"); + assert.equal(createdConfig.streaming, true); + assert.equal(typeof createdConfig.onPermissionRequest, "function"); + assert.equal(typeof createdConfig.onUserInputRequest, "function"); + }), + ); + + it.effect("rejects a non-Copilot modelSelection", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-no-model"), + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "copilot", + operation: "startSession", + issue: "Expected modelSelection.provider 'copilot', received 'codex'.", + }), + ); + assert.equal(reasoningClient.startImpl.mock.calls.length, 0); + assert.equal(reasoningClient.listModelsImpl.mock.calls.length, 0); + assert.equal(reasoningClient.createSessionImpl.mock.calls.length, 0); + }), + ); + + it.effect("rejects unsupported reasoning effort for a valid model", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.stopImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium"], + }), + ] as never); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-invalid"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "xhigh"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "copilot", + operation: "session.reasoningEffort", + issue: "GitHub Copilot model 'gpt-5.4' does not support reasoning effort 'xhigh'.", + }), + ); + assert.equal(reasoningClient.createSessionImpl.mock.calls.length, 0); + assert.equal(reasoningClient.stopImpl.mock.calls.length, 1); + }), + ); + + it.effect("stops the Copilot client when session creation throws", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.stopImpl.mockClear(); + reasoningClient.createSessionImpl.mockImplementationOnce(async () => { + throw new Error("session creation failed"); + }); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-session-create-failure"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + assert.equal( + (result.failure as ProviderAdapterProcessError).detail, + "session creation failed", + ); + assert.equal(reasoningClient.createSessionImpl.mock.calls.length, 1); + assert.equal(reasoningClient.stopImpl.mock.calls.length, 1); + }), + ); + + it.effect("reconfigures the session when reasoning effort changes", () => + Effect.gen(function* () { + reasoningSession.modelSwitchToImpl.mockClear(); + reasoningSession.disconnectImpl.mockClear(); + reasoningSession.destroyImpl.mockClear(); + reasoningSession.sendImpl.mockClear(); + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.resumeSessionImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }), + ] as never); + + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-reconfigure"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "high"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Switch effort", + modelSelection: makeCopilotModelSelection("gpt-5.4", "low"), + attachments: [], + }); + + assert.deepStrictEqual(reasoningSession.modelSwitchToImpl.mock.calls, [ + [{ modelId: "gpt-5.4", reasoningEffort: "low" }], + ]); + assert.equal(reasoningSession.disconnectImpl.mock.calls.length, 0); + assert.equal(reasoningSession.destroyImpl.mock.calls.length, 0); + assert.equal(reasoningClient.resumeSessionImpl.mock.calls.length, 0); + assert.equal(reasoningSession.sendImpl.mock.calls.length, 1); + }), + ); +}); + +let toolEventSession: FakeCopilotSession; +let toolEventClient: FakeCopilotClient; + +beforeEach(() => { + toolEventSession = new FakeCopilotSession("copilot-session-tool-events"); + toolEventClient = new FakeCopilotClient(toolEventSession); +}); + +const toolEventLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => toolEventClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +toolEventLayer("CopilotAdapterLive tool event mapping", (it) => { + it.effect("maps Copilot tool events to canonical lifecycle item types", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-events"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Inspect and edit a file", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolEventSession.emit({ + id: "evt-tool-start-command", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-command", + toolName: "bash", + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-complete-command", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-command", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-command", + success: true, + result: { + content: "ok", + }, + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-start-write", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-command", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-write", + toolName: "write_file", + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-complete-write", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-write", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-write", + success: true, + result: { + content: "done", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)).filter( + (event) => event.type === "item.started" || event.type === "item.completed", + ); + assert.deepStrictEqual( + events.map((event) => + "payload" in event && event.payload && typeof event.payload === "object" + ? { + type: event.type, + itemType: "itemType" in event.payload ? event.payload.itemType : undefined, + title: "title" in event.payload ? event.payload.title : undefined, + } + : { type: event.type, itemType: undefined, title: undefined }, + ), + [ + { type: "item.started", itemType: "command_execution", title: "Command run" }, + { type: "item.completed", itemType: "command_execution", title: "Command run" }, + { type: "item.started", itemType: "file_change", title: "File change" }, + { type: "item.completed", itemType: "file_change", title: "File change" }, + ], + ); + }), + ); +}); + +let toolTitleSession: FakeCopilotSession; +let toolTitleClient: FakeCopilotClient; + +beforeEach(() => { + toolTitleSession = new FakeCopilotSession("copilot-session-tool-titles"); + toolTitleClient = new FakeCopilotClient(toolTitleSession); +}); + +const toolTitleLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => toolTitleClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +toolTitleLayer("CopilotAdapterLive tool titles", (it) => { + it.effect("uses specific titles for Copilot SDK read and search tools", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-titles"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Read and search files", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-view", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-view", + toolName: "view", + arguments: { + path: "README.md", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-view", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-view", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-view", + success: true, + result: { + content: "read ok", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-start-grep", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-view", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-grep", + toolName: "grep", + arguments: { + pattern: "Copilot", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-grep", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-grep", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-grep", + success: true, + result: { + content: "match", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-start-list-directory", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-grep", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-list-directory", + toolName: "list_directory", + arguments: { + path: ".", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-list-directory", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-list-directory", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-list-directory", + success: true, + result: { + content: "listed", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)).filter( + (event) => event.type === "item.completed", + ); + assert.deepStrictEqual( + events.map((event) => + event.payload && typeof event.payload === "object" + ? { + itemType: event.payload.itemType, + title: event.payload.title, + detail: event.payload.detail, + } + : null, + ), + [ + { + itemType: "dynamic_tool_call", + title: "Read file", + detail: "README.md", + }, + { + itemType: "dynamic_tool_call", + title: "Grep", + detail: "Copilot", + }, + { + itemType: "dynamic_tool_call", + title: "List directory", + detail: ".", + }, + ], + ); + }), + ); + + it.effect("uses detailedContent for completed tool detail and content for tool summary", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-detailed-content"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Inspect a diff", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-detailed", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-detailed", + toolName: "edit", + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-detailed", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-detailed", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-detailed", + success: true, + result: { + content: "Updated file", + detailedContent: "Updated file\n--- a/file.ts\n+++ b/file.ts\n+const value = 1;", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + const summaryEvent = events.find((event) => event.type === "tool.summary"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal( + completedEvent.payload.detail, + "Updated file\n--- a/file.ts\n+++ b/file.ts\n+const value = 1;", + ); + } + + assert.equal(summaryEvent?.type, "tool.summary"); + if (summaryEvent?.type === "tool.summary") { + assert.equal(summaryEvent.payload.summary, "Updated file"); + } + }), + ); + + it.effect("keeps diff-like detailedContent from read tools as a read-style tool call", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-diff-file-change"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Apply a patch", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-diff", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-diff", + toolName: "view", + arguments: { + path: "apps/web/src/foo.ts", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-diff", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-diff", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-diff", + success: true, + result: { + content: "Updated file", + detailedContent: diffDetailedContent("apps/web/src/foo.ts"), + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal(completedEvent.payload.itemType, "dynamic_tool_call"); + assert.equal(completedEvent.payload.title, "Read file"); + assert.equal(completedEvent.payload.detail, "apps/web/src/foo.ts"); + assert.deepStrictEqual( + (completedEvent.payload.data as { changes?: Array<{ path: string }> }).changes, + undefined, + ); + } + }), + ); + + it.effect("maps diff-like detailedContent from edit tools to a file change completion", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-edit-diff-file-change"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Apply an edit", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-edit-diff", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-edit-diff", + toolName: "edit", + arguments: { + path: "apps/web/src/foo.ts", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-edit-diff", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-edit-diff", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-edit-diff", + success: true, + result: { + content: "Updated file", + detailedContent: diffDetailedContent("apps/web/src/foo.ts"), + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal(completedEvent.payload.itemType, "file_change"); + assert.equal(completedEvent.payload.title, "File change"); + assert.equal(completedEvent.payload.detail, "apps/web/src/foo.ts"); + assert.deepStrictEqual( + (completedEvent.payload.data as { changes?: Array<{ path: string }> }).changes, + [{ path: "apps/web/src/foo.ts" }], + ); + } + }), + ); +}); + +afterAll(() => { + void modeSession.disconnect(); + void modeClient.stop(); + void planSession.disconnect(); + void planClient.stop(); + void reasoningSession.disconnect(); + void reasoningClient.stop(); + void toolEventSession.disconnect(); + void toolEventClient.stop(); + void toolTitleSession.disconnect(); + void toolTitleClient.stop(); +}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 000000000..44672553a --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,2115 @@ +import { randomUUID } from "node:crypto"; + +import { + type CopilotModelSelection, + type CodexReasoningEffort, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ThreadTokenUsageSnapshot, + type ToolLifecycleItemType, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + CopilotClient, + type CopilotClientOptions, + type PermissionRequest, + type PermissionRequestResult, + type SessionEvent, +} from "@github/copilot-sdk"; +import { Effect, Exit, Layer, Queue, Scope, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + assistantUsageFields, + beginCopilotTurn, + clearTurnTracking, + completionTurnRefs, + isCopilotTurnTerminalEvent, + markTurnAwaitingCompletion, + recordTurnUsage, + type CopilotTurnTrackingState, +} from "./copilotTurnTracking.ts"; +import { + loadCopilotSupportedModels, + resolveCopilotRuntimeConfig, + resolveCopilotSelectedModel, + stopCopilotClient, + trimToUndefined, + validateCopilotReasoningEffort, +} from "./copilotSdk.ts"; +import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import type { + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "copilot" as const; +const USER_INPUT_QUESTION_ID = "answer"; +const USER_INPUT_QUESTION_HEADER = "Question"; + +export interface CopilotAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; + readonly clientFactory?: (options: CopilotClientOptions) => CopilotClientHandle; +} + +interface PendingApprovalRequest { + readonly requestType: + | "command_execution_approval" + | "file_change_approval" + | "file_read_approval" + | "dynamic_tool_call" + | "unknown"; + readonly turnId: TurnId | undefined; + readonly resolve: (result: PermissionRequestResult) => void; +} + +interface CopilotUserInputRequest { + readonly question: string; + readonly choices?: ReadonlyArray; + readonly allowFreeform?: boolean; +} + +interface CopilotUserInputResponse { + readonly answer: string; + readonly wasFreeform: boolean; +} + +interface PendingUserInputRequest { + readonly request: CopilotUserInputRequest; + readonly turnId: TurnId | undefined; + readonly resolve: (result: CopilotUserInputResponse) => void; +} + +interface ActiveCopilotSession extends CopilotTurnTrackingState { + readonly client: CopilotClientHandle; + readonly sessionScope: Scope.Closeable; + session: CopilotSessionHandle; + readonly threadId: ThreadId; + readonly createdAt: string; + readonly runtimeMode: ProviderSession["runtimeMode"]; + cwd: string | undefined; + configDir: string | undefined; + model: string | undefined; + reasoningEffort: CodexReasoningEffort | undefined; + interactionMode: "default" | "plan" | undefined; + updatedAt: string; + lastError: string | undefined; + toolItemTypesByCallId: Map; + toolTitlesByCallId: Map; + toolDetailsByCallId: Map; + pendingApprovalResolvers: Map; + pendingUserInputResolvers: Map; + unsubscribe: () => void; +} + +interface CopilotSessionHandle { + readonly sessionId: string; + readonly rpc: { + readonly model: { + switchTo(input: { modelId: string; reasoningEffort?: string }): Promise<{ + modelId?: string; + }>; + }; + readonly mode: { + set(input: { mode: "interactive" | "plan" | "autopilot" }): Promise<{ + mode: "interactive" | "plan" | "autopilot"; + }>; + }; + readonly plan: { + read(): Promise<{ + exists: boolean; + content: string | null; + path: string | null; + }>; + }; + }; + disconnect?(): Promise; + destroy(): Promise; + on(handler: (event: SessionEvent) => void): () => void; + send(options: { prompt: string; attachments?: unknown; mode?: string }): Promise; + abort(): Promise; + getMessages(): Promise; +} + +interface CopilotClientHandle { + start(): Promise; + listModels(): Promise>; + createSession( + config: Parameters[0], + ): Promise; + resumeSession( + sessionId: string, + config: Parameters[1], + ): Promise; + stop(): Promise; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function makeEventId(prefix: string) { + return EventId.makeUnsafe(`${prefix}-${randomUUID()}`); +} + +function toTurnId(value: string | undefined): TurnId | undefined { + if (!value || value.trim().length === 0) return undefined; + return TurnId.makeUnsafe(value); +} + +function toRuntimeItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeItemId.makeUnsafe(value); +} + +function toProviderItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return ProviderItemId.makeUnsafe(value); +} + +function toRuntimeRequestId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeRequestId.makeUnsafe(value); +} + +function toRuntimeTaskId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeTaskId.makeUnsafe(value); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function makeCopilotSessionId(threadId: ThreadId): string { + return `t3code-copilot-${threadId}`; +} + +async function closeCopilotSession(session: CopilotSessionHandle): Promise { + if (typeof session.disconnect === "function") { + await session.disconnect(); + return; + } + await session.destroy(); +} + +function getCopilotModelSelection( + input: Pick, +): CopilotModelSelection | undefined { + return input.modelSelection?.provider === PROVIDER ? input.modelSelection : undefined; +} + +function getCopilotReasoningEffort( + modelSelection: CopilotModelSelection | undefined, +): CodexReasoningEffort | undefined { + return modelSelection?.options?.reasoningEffort; +} + +function normalizeCopilotSessionTokenUsage( + usage: Extract["data"], +): ThreadTokenUsageSnapshot | undefined { + if (usage.currentTokens <= 0) { + return undefined; + } + + return { + usedTokens: usage.currentTokens, + lastUsedTokens: usage.currentTokens, + ...(usage.tokenLimit > 0 ? { maxTokens: usage.tokenLimit } : {}), + compactsAutomatically: true, + }; +} + +function normalizeCopilotAssistantTokenUsage( + usage: Extract["data"], +): ThreadTokenUsageSnapshot | undefined { + const inputTokens = usage.inputTokens ?? 0; + const cachedInputTokens = usage.cacheReadTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + const usedTokens = inputTokens + cachedInputTokens + outputTokens; + + if (usedTokens <= 0) { + return undefined; + } + + const totalProcessedTokens = usedTokens + (usage.cacheWriteTokens ?? 0); + return { + usedTokens, + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), + ...(inputTokens > 0 ? { inputTokens } : {}), + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + ...(outputTokens > 0 ? { outputTokens } : {}), + lastUsedTokens: usedTokens, + ...(inputTokens > 0 ? { lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens > 0 ? { lastCachedInputTokens: cachedInputTokens } : {}), + ...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}), + ...(usage.duration !== undefined && usage.duration >= 0 ? { durationMs: usage.duration } : {}), + compactsAutomatically: true, + }; +} + +function extractResumeSessionId(resumeCursor: unknown): string | undefined { + if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { + return resumeCursor.trim(); + } + const record = asRecord(resumeCursor); + const sessionId = normalizeString(record?.sessionId); + return sessionId; +} + +function toCopilotSessionMode(interactionMode: "default" | "plan"): "interactive" | "plan" { + return interactionMode === "plan" ? "plan" : "interactive"; +} + +function toInteractionMode(mode: string): "default" | "plan" { + return mode === "plan" ? "plan" : "default"; +} + +function approvalDecisionToPermissionResult( + decision: ProviderApprovalDecision, +): PermissionRequestResult { + switch (decision) { + case "accept": + case "acceptForSession": + return { kind: "approved" }; + case "decline": + case "cancel": + default: + return { kind: "denied-interactively-by-user" }; + } +} + +function requestTypeFromPermissionRequest(request: PermissionRequest) { + switch (request.kind) { + case "shell": + return "command_execution_approval" as const; + case "write": + return "file_change_approval" as const; + case "read": + return "file_read_approval" as const; + case "custom-tool": + return classifyToolRequestType( + trimToUndefined(String(request.toolTitle ?? request.toolName ?? "")) ?? "tool", + ); + case "mcp": + return classifyToolRequestType( + trimToUndefined( + String(request.toolTitle ?? request.toolName ?? request.mcpToolName ?? ""), + ) ?? "tool", + ); + case "url": + return "dynamic_tool_call" as const; + default: + return "unknown" as const; + } +} + +function normalizeToolName(toolName: string): string { + return toolName.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function toolDisplayTitle(toolName: string): string | undefined { + const normalized = normalizeToolName(toolName); + + if ( + normalized === "view" || + normalized === "read" || + normalized === "read file" || + normalized === "read files" || + normalized === "view file" || + normalized === "view files" + ) { + return "Read file"; + } + + if (normalized === "grep") { + return "Grep"; + } + + if (normalized === "glob") { + return "Glob"; + } + + if ( + normalized === "search code" || + normalized === "search file" || + normalized === "search files" || + normalized === "find file" || + normalized === "find files" + ) { + return "Search files"; + } + + if (normalized === "list directory" || normalized === "list directories") { + return "List directory"; + } + + return undefined; +} + +function isReadOnlyToolName(toolName: string): boolean { + const normalized = normalizeToolName(toolName); + return ( + normalized === "read" || + normalized.startsWith("read ") || + normalized.includes("read file") || + normalized === "view" || + normalized.startsWith("view ") || + normalized.includes("view file") || + normalized === "grep" || + normalized === "glob" || + normalized.includes("search code") || + normalized.includes("search file") || + normalized.includes("find file") || + normalized.includes("list directory") + ); +} + +function classifyToolItemType(toolName: string): ToolLifecycleItemType { + const normalized = normalizeToolName(toolName); + + if ( + normalized.includes("agent") || + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") + ) { + return "collab_agent_tool_call"; + } + + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + + if (normalized.includes("websearch") || normalized.includes("web search")) { + return "web_search"; + } + + if (normalized.includes("image")) { + return "image_view"; + } + + if (isReadOnlyToolName(toolName)) { + return "dynamic_tool_call"; + } + + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") || + normalized.includes("rename") || + normalized.includes("move") || + normalized.includes("modify") || + normalized.includes("apply") + ) { + return "file_change"; + } + + return "dynamic_tool_call"; +} + +function classifyToolRequestType( + toolName: string, +): + | "command_execution_approval" + | "file_change_approval" + | "file_read_approval" + | "dynamic_tool_call" { + if (isReadOnlyToolName(toolName)) { + return "file_read_approval"; + } + + const itemType = classifyToolItemType(toolName); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : "dynamic_tool_call"; +} + +function requestDetailFromPermissionRequest(request: PermissionRequest): string | undefined { + switch (request.kind) { + case "shell": + return trimToUndefined(String(request.fullCommandText ?? "")); + case "write": + return trimToUndefined(String(request.fileName ?? request.intention ?? "")); + case "read": + return trimToUndefined(String(request.path ?? request.intention ?? "")); + case "mcp": + return trimToUndefined(String(request.toolTitle ?? request.toolName ?? "")); + case "url": + return trimToUndefined(String(request.url ?? request.intention ?? "")); + case "custom-tool": + return trimToUndefined(String(request.toolName ?? request.toolDescription ?? "")); + default: + return undefined; + } +} + +function itemTypeFromToolEvent(event: Extract) { + return event.data.mcpToolName ? "mcp_tool_call" : classifyToolItemType(event.data.toolName); +} + +function toolTitleFromItemType(itemType: ToolLifecycleItemType, toolName?: string): string { + if (toolName && itemType === "dynamic_tool_call") { + const dynamicToolTitle = toolDisplayTitle(toolName); + if (dynamicToolTitle) { + return dynamicToolTitle; + } + } + + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "collab_agent_tool_call": + return "Subagent task"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + case "dynamic_tool_call": + return "Tool call"; + } +} + +function summarizeArgumentList(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const values = value + .map((entry) => normalizeString(entry)) + .filter((entry): entry is string => entry !== undefined); + const [firstValue] = values; + if (!firstValue) { + return undefined; + } + return values.length === 1 ? firstValue : `${firstValue} +${values.length - 1} more`; +} + +function toolArgumentDetail(argumentsValue: { readonly [k: string]: unknown } | undefined) { + if (!argumentsValue) { + return undefined; + } + + for (const key of ["path", "directory", "dir", "pattern", "glob", "query", "url", "command"]) { + const value = normalizeString(argumentsValue[key]); + if (value) { + return value; + } + } + + for (const key of ["paths", "files", "globs", "patterns"]) { + const value = summarizeArgumentList(argumentsValue[key]); + if (value) { + return value; + } + } + + return undefined; +} + +function toolDetailFromEvent(data: { + readonly toolName?: string; + readonly mcpToolName?: string; + readonly mcpServerName?: string; + readonly arguments?: { + readonly [k: string]: unknown; + }; +}) { + const argumentDetail = toolArgumentDetail(data.arguments); + if (argumentDetail) { + return argumentDetail; + } + if (data.mcpToolName || data.mcpServerName) { + return trimToUndefined([data.mcpServerName, data.mcpToolName ?? data.toolName].join(" / ")); + } + return undefined; +} + +function toolResultSummaryContent( + result: { readonly content?: string } | undefined, +): string | undefined { + return trimToUndefined(result?.content); +} + +function toolResultDetailContent( + result: + | { + readonly content?: string; + readonly detailedContent?: string; + } + | undefined, +): string | undefined { + return trimToUndefined(result?.detailedContent) ?? trimToUndefined(result?.content); +} + +function completedToolDetail(input: { + readonly itemType: ToolLifecycleItemType; + readonly success: boolean; + readonly startedDetail: string | undefined; + readonly resultDetail: string | undefined; +}): string | undefined { + if (!input.success) { + return input.resultDetail ?? input.startedDetail; + } + + if ( + input.startedDetail && + (input.itemType === "dynamic_tool_call" || + input.itemType === "file_change" || + input.itemType === "web_search" || + input.itemType === "image_view") + ) { + return input.startedDetail; + } + + return input.resultDetail ?? input.startedDetail; +} + +function looksLikeDiffDetail(detail: string | undefined): boolean { + if (!detail) { + return false; + } + const normalized = detail.trim(); + return ( + normalized.startsWith("diff --git ") || + (/^---\s/m.test(normalized) && /^\+\+\+\s/m.test(normalized)) + ); +} + +function normalizeDiffPath(value: string): string { + if (value === "/dev/null") { + return value; + } + if (value.startsWith("a/") || value.startsWith("b/")) { + return value.slice(2); + } + return value; +} + +function extractChangedFilesFromDiff(detail: string | undefined): string[] { + if (!looksLikeDiffDetail(detail)) { + return []; + } + const normalizedDetail = detail?.trim(); + if (!normalizedDetail) { + return []; + } + + const changedFiles: string[] = []; + const seen = new Set(); + const pushChangedFile = (value: string | undefined) => { + const normalized = trimToUndefined(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + changedFiles.push(normalized); + }; + + for (const match of normalizedDetail.matchAll( + /^diff --git\s+(?\S+)\s+(?\S+)$/gm, + )) { + const beforePath = normalizeDiffPath(match.groups?.before ?? ""); + const afterPath = normalizeDiffPath(match.groups?.after ?? ""); + pushChangedFile(afterPath !== "/dev/null" ? afterPath : beforePath); + } + + if (changedFiles.length > 0) { + return changedFiles; + } + + for (const match of normalizedDetail.matchAll(/^(?:---|\+\+\+)\s+(?\S+)$/gm)) { + const path = normalizeDiffPath(match.groups?.path ?? ""); + if (path === "/dev/null") { + continue; + } + pushChangedFile(path); + } + + return changedFiles; +} + +function withRefs(input: { + readonly threadId: ThreadId; + readonly eventId: EventId; + readonly createdAt: string; + readonly turnId: TurnId | undefined; + readonly providerTurnId?: TurnId | undefined; + readonly itemId: string | undefined; + readonly requestId: string | undefined; + readonly rawMethod: string | undefined; + readonly rawPayload: unknown; +}): Omit { + const providerTurnId = input.providerTurnId ?? input.turnId; + const runtimeItemId = toRuntimeItemId(input.itemId); + const runtimeRequestId = toRuntimeRequestId(input.requestId); + const providerItemId = toProviderItemId(input.itemId); + const providerRequestId = trimToUndefined(input.requestId); + return { + eventId: input.eventId, + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(runtimeItemId ? { itemId: runtimeItemId } : {}), + ...(runtimeRequestId ? { requestId: runtimeRequestId } : {}), + ...(providerTurnId || providerItemId || providerRequestId + ? { + providerRefs: { + ...(providerTurnId ? { providerTurnId } : {}), + ...(providerItemId ? { providerItemId } : {}), + ...(providerRequestId ? { providerRequestId } : {}), + }, + } + : {}), + raw: { + source: input.rawMethod ? "copilot.sdk.session-event" : "copilot.sdk.synthetic", + ...(input.rawMethod ? { method: input.rawMethod } : {}), + payload: input.rawPayload, + }, + }; +} + +function mapHistoryToTurns( + threadId: ThreadId, + events: ReadonlyArray, +): ProviderThreadSnapshot { + const turns: Array = []; + let current: { id: TurnId; items: Array } | undefined; + + for (const event of events) { + if (event.type === "assistant.turn_start") { + current = { + id: TurnId.makeUnsafe(event.data.turnId), + items: [event], + }; + turns.push(current); + continue; + } + + if (!current) { + continue; + } + + current.items.push(event); + if (isCopilotTurnTerminalEvent(event)) { + current = undefined; + } + } + + return { + threadId, + turns: turns.map((turn) => ({ + id: turn.id, + items: turn.items, + })), + }; +} + +function makeSyntheticEvent( + threadId: ThreadId, + type: ProviderRuntimeEvent["type"], + payload: ProviderRuntimeEvent["payload"], + extra?: { + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }, +): ProviderRuntimeEvent { + return { + ...withRefs({ + threadId, + eventId: makeEventId("copilot-synthetic"), + createdAt: new Date().toISOString(), + turnId: extra?.turnId, + itemId: extra?.itemId, + requestId: extra?.requestId, + rawMethod: undefined, + rawPayload: payload, + }), + type, + payload, + } as ProviderRuntimeEvent; +} + +function resolveUserInputAnswer( + pending: PendingUserInputRequest, + answers: ProviderUserInputAnswers, +): CopilotUserInputResponse { + const direct = answers[USER_INPUT_QUESTION_ID]; + const candidate = + typeof direct === "string" + ? direct + : Object.values(answers).find((value): value is string => typeof value === "string"); + const answer = trimToUndefined(candidate) ?? ""; + return { + answer, + wasFreeform: !pending.request.choices?.includes(answer), + }; +} + +function createSessionRecord(input: { + readonly threadId: ThreadId; + readonly client: CopilotClientHandle; + readonly sessionScope: Scope.Closeable; + readonly session: CopilotSessionHandle; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly pendingApprovalResolvers: Map; + readonly pendingUserInputResolvers: Map; + readonly cwd: string | undefined; + readonly configDir: string | undefined; + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; +}): ActiveCopilotSession { + return { + client: input.client, + sessionScope: input.sessionScope, + session: input.session, + threadId: input.threadId, + createdAt: new Date().toISOString(), + runtimeMode: input.runtimeMode, + cwd: input.cwd, + configDir: input.configDir, + model: input.model, + reasoningEffort: input.reasoningEffort, + interactionMode: undefined, + updatedAt: new Date().toISOString(), + lastError: undefined, + currentTurnId: undefined, + currentProviderTurnId: undefined, + pendingCompletionTurnId: undefined, + pendingCompletionProviderTurnId: undefined, + pendingTurnIds: [], + pendingTurnUsage: undefined, + toolItemTypesByCallId: new Map(), + toolTitlesByCallId: new Map(), + toolDetailsByCallId: new Map(), + pendingApprovalResolvers: input.pendingApprovalResolvers, + pendingUserInputResolvers: input.pendingUserInputResolvers, + unsubscribe: () => undefined, + }; +} + +const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const nativeEventLogger = options?.nativeEventLogger; + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + + const emitRuntimeEvents = (events: ReadonlyArray) => + Effect.runPromise(Queue.offerAll(runtimeEventQueue, events).pipe(Effect.asVoid)).catch( + () => undefined, + ); + + const writeNativeEvent = (threadId: ThreadId, event: SessionEvent) => { + if (!nativeEventLogger) return Promise.resolve(); + return Effect.runPromise(nativeEventLogger.write(event, threadId)).catch(() => undefined); + }; + + const currentSyntheticTurnId = (record: ActiveCopilotSession) => + completionTurnRefs(record).turnId ?? record.currentTurnId; + + const syncInteractionMode = ( + record: ActiveCopilotSession, + interactionMode: "default" | "plan", + ) => { + if (record.interactionMode === interactionMode) { + return Effect.void; + } + return Effect.tryPromise({ + try: async () => { + await record.session.rpc.mode.set({ + mode: toCopilotSessionMode(interactionMode), + }); + record.interactionMode = interactionMode; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.mode.set", + detail: toMessage(cause, "Failed to switch GitHub Copilot interaction mode."), + cause, + }), + }); + }; + + const emitLatestProposedPlan = (record: ActiveCopilotSession) => + Effect.tryPromise({ + try: () => record.session.rpc.plan.read(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.plan.read", + detail: toMessage(cause, "Failed to read the GitHub Copilot plan."), + cause, + }), + }).pipe( + Effect.flatMap((plan) => { + const planMarkdown = trimToUndefined(plan.content ?? undefined); + if (!plan.exists || !planMarkdown) { + return Effect.void; + } + return Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + record.threadId, + "turn.proposed.completed", + { + planMarkdown, + }, + { turnId: currentSyntheticTurnId(record) }, + ), + ).pipe(Effect.asVoid); + }), + ); + + const mapSessionEvent = ( + record: ActiveCopilotSession, + event: SessionEvent, + ): ReadonlyArray => { + const currentTurnId = record.currentTurnId; + const currentProviderTurnId = record.currentProviderTurnId; + const resolveOrchestrationTurnId = ( + providerTurnId: TurnId | undefined, + ): TurnId | undefined => { + if (providerTurnId && currentProviderTurnId && providerTurnId === currentProviderTurnId) { + return currentTurnId ?? providerTurnId; + } + return currentTurnId ?? providerTurnId; + }; + const base = (input?: { + readonly turnId?: TurnId | undefined; + readonly providerTurnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }) => + withRefs({ + threadId: record.threadId, + eventId: EventId.makeUnsafe(event.id), + createdAt: event.timestamp, + turnId: resolveOrchestrationTurnId(input?.providerTurnId ?? input?.turnId), + providerTurnId: input?.providerTurnId ?? input?.turnId, + itemId: input?.itemId, + requestId: input?.requestId, + rawMethod: event.type, + rawPayload: event, + }); + + switch (event.type) { + case "session.start": + case "session.resume": + return [ + { + ...base(), + type: "session.started", + payload: { + message: + event.type === "session.resume" + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: event.data, + }, + }, + { + ...base(), + type: "thread.started", + payload: { + providerThreadId: + event.type === "session.start" ? event.data.sessionId : record.session.sessionId, + }, + }, + ]; + case "session.info": + return [ + { + ...base(), + type: "runtime.warning", + payload: { + message: event.data.message, + detail: event.data, + }, + }, + ]; + case "session.warning": + return [ + { + ...base(), + type: "runtime.warning", + payload: { + message: event.data.message, + detail: event.data, + }, + }, + ]; + case "session.error": + return [ + { + ...base(), + type: "runtime.error", + payload: { + message: event.data.message, + class: "provider_error", + detail: event.data, + }, + }, + { + ...base(), + type: "session.state.changed", + payload: { + state: "error", + reason: "session.error", + detail: event.data, + }, + }, + ]; + case "session.idle": { + const idleCompletionRefs = completionTurnRefs(record); + const idleCompletionEvents: ProviderRuntimeEvent[] = + idleCompletionRefs.turnId || idleCompletionRefs.providerTurnId + ? [ + { + ...base(idleCompletionRefs), + type: "turn.completed", + payload: { + state: "completed", + ...assistantUsageFields(record.pendingTurnUsage), + }, + } satisfies ProviderRuntimeEvent, + ] + : []; + return [ + ...idleCompletionEvents, + { + ...base(), + type: "session.state.changed", + payload: { + state: "ready", + reason: "session.idle", + }, + }, + { + ...base(), + type: "thread.state.changed", + payload: { + state: "idle", + detail: event.data, + }, + }, + ]; + } + case "session.title_changed": + return [ + { + ...base(), + type: "thread.metadata.updated", + payload: { + name: event.data.title, + metadata: event.data, + }, + }, + ]; + case "session.model_change": + return [ + { + ...base(), + type: "model.rerouted", + payload: { + fromModel: event.data.previousModel ?? "unknown", + toModel: event.data.newModel, + reason: "session.model_change", + }, + }, + ]; + case "session.plan_changed": + return [ + { + ...base(), + type: "turn.plan.updated", + payload: { + explanation: `Plan ${event.data.operation}d`, + plan: [], + }, + }, + ]; + case "session.workspace_file_changed": + return [ + { + ...base(), + type: "files.persisted", + payload: { + files: [ + { + filename: event.data.path, + fileId: event.data.path, + }, + ], + }, + }, + ]; + case "session.context_changed": + return [ + { + ...base(), + type: "thread.metadata.updated", + payload: { + metadata: event.data, + }, + }, + ]; + case "session.usage_info": { + const usage = normalizeCopilotSessionTokenUsage(event.data); + if (!usage) { + return []; + } + return [ + { + ...base(), + type: "thread.token-usage.updated", + payload: { + usage, + }, + }, + ]; + } + case "session.task_complete": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(record.threadId) ?? RuntimeTaskId.makeUnsafe(record.threadId), + status: "completed", + ...(trimToUndefined(event.data.summary) ? { summary: event.data.summary } : {}), + }, + }, + ]; + case "assistant.turn_start": + return [ + { + ...base({ providerTurnId: toTurnId(event.data.turnId) }), + type: "turn.started", + payload: record.model ? { model: record.model } : {}, + }, + { + ...base({ providerTurnId: toTurnId(event.data.turnId) }), + type: "session.state.changed", + payload: { + state: "running", + reason: "assistant.turn_start", + }, + }, + ]; + case "assistant.reasoning": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.reasoning_delta": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.message": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.message_delta": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.turn_end": + return []; + case "assistant.usage": { + const usage = normalizeCopilotAssistantTokenUsage(event.data); + if (!usage) { + return []; + } + const completionRefs = completionTurnRefs(record); + const completionBase = + completionRefs.turnId || completionRefs.providerTurnId ? base(completionRefs) : base(); + return [ + { + ...completionBase, + type: "thread.token-usage.updated", + payload: { + usage, + }, + }, + ]; + } + case "abort": { + const abortedTurnRefs = completionTurnRefs(record); + const abortedBase = + abortedTurnRefs.turnId || abortedTurnRefs.providerTurnId + ? base(abortedTurnRefs) + : base(); + return [ + { + ...abortedBase, + type: "turn.aborted", + payload: { + reason: event.data.reason, + }, + }, + ]; + } + case "tool.execution_start": { + const startedItemType = itemTypeFromToolEvent(event); + const startedTitle = toolTitleFromItemType(startedItemType, event.data.toolName); + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.started", + payload: { + itemType: startedItemType, + status: "inProgress", + title: startedTitle, + ...(toolDetailFromEvent(event.data) + ? { detail: toolDetailFromEvent(event.data) } + : {}), + data: event.data, + }, + }, + ]; + } + case "tool.execution_progress": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.progressMessage, + }, + }, + ]; + case "tool.execution_partial_result": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.partialOutput, + }, + }, + ]; + case "tool.execution_complete": { + const resultDetail = toolResultDetailContent(event.data.result); + const completedItemType = + record.toolItemTypesByCallId.get(event.data.toolCallId) ?? + (event.data.result?.contents?.some((content) => content.type === "terminal") + ? "command_execution" + : "dynamic_tool_call"); + const diffChangedFiles = + completedItemType === "file_change" ? extractChangedFilesFromDiff(resultDetail) : []; + const completedTitle = + record.toolTitlesByCallId.get(event.data.toolCallId) ?? + toolTitleFromItemType(completedItemType); + const completedDetail = completedToolDetail({ + itemType: completedItemType, + success: event.data.success, + startedDetail: record.toolDetailsByCallId.get(event.data.toolCallId), + resultDetail, + }); + const completedSummary = toolResultSummaryContent(event.data.result); + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.completed", + payload: { + itemType: completedItemType, + status: event.data.success ? "completed" : "failed", + title: completedTitle, + ...(completedDetail ? { detail: completedDetail } : {}), + data: + diffChangedFiles.length > 0 + ? { + ...event.data, + changes: diffChangedFiles.map((path) => ({ path })), + } + : event.data, + }, + }, + ...(completedSummary + ? [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.summary" as const, + payload: { + summary: completedSummary, + precedingToolUseIds: [event.data.toolCallId], + }, + }, + ] + : []), + ]; + } + case "skill.invoked": + return [ + { + ...base(), + type: "task.progress", + payload: { + taskId: + toRuntimeTaskId(event.data.name) ?? RuntimeTaskId.makeUnsafe(event.data.name), + description: `Invoked skill ${event.data.name}`, + }, + }, + ]; + case "subagent.started": + return [ + { + ...base(), + type: "task.started", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + description: trimToUndefined(event.data.agentDescription), + taskType: "subagent", + }, + }, + ]; + case "subagent.completed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "completed", + ...(trimToUndefined(event.data.agentDisplayName) + ? { summary: event.data.agentDisplayName } + : {}), + }, + }, + ]; + case "subagent.failed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "failed", + ...(trimToUndefined(event.data.error) ? { summary: event.data.error } : {}), + }, + }, + ]; + default: + return []; + } + }; + + const createInteractionHandlers = ( + threadId: ThreadId, + getCurrentTurnId: () => TurnId | undefined, + getRuntimeMode: () => ProviderSession["runtimeMode"], + pendingApprovalResolvers: Map, + pendingUserInputResolvers: Map, + ) => { + const onPermissionRequest = (request: PermissionRequest) => + getRuntimeMode() === "full-access" + ? Promise.resolve({ kind: "approved" }) + : new Promise((resolve) => { + const requestId = `copilot-approval-${randomUUID()}`; + const turnId = getCurrentTurnId(); + pendingApprovalResolvers.set(requestId, { + requestType: requestTypeFromPermissionRequest(request), + turnId, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "request.opened", + { + requestType: requestTypeFromPermissionRequest(request), + ...(requestDetailFromPermissionRequest(request) + ? { detail: requestDetailFromPermissionRequest(request) } + : {}), + args: request, + }, + { requestId, turnId }, + ), + ]); + }); + + const onUserInputRequest = (request: CopilotUserInputRequest) => + new Promise((resolve) => { + const requestId = `copilot-user-input-${randomUUID()}`; + const turnId = getCurrentTurnId(); + pendingUserInputResolvers.set(requestId, { + request, + turnId, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "user-input.requested", + { + questions: [ + { + id: USER_INPUT_QUESTION_ID, + header: USER_INPUT_QUESTION_HEADER, + question: request.question, + options: (request.choices ?? []).map((choice: string) => ({ + label: choice, + description: choice, + })), + }, + ], + }, + { requestId, turnId }, + ), + ]); + }); + + return { + onPermissionRequest, + onUserInputRequest, + }; + }; + + const validateSessionConfiguration = (input: { + readonly client: CopilotClientHandle; + readonly threadId: ThreadId; + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }) => + Effect.gen(function* () { + if (!input.model && !input.reasoningEffort) { + return; + } + + const supportedModels = yield* loadCopilotSupportedModels({ + client: input.client, + onStartError: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot client."), + cause, + }), + onListError: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to load GitHub Copilot model metadata."), + cause, + }), + }); + const selectedModel = yield* resolveCopilotSelectedModel({ + supportedModels, + model: input.model, + onMissingModel: (model) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.model", + issue: `GitHub Copilot model '${model}' is not available in the current Copilot runtime.`, + }), + }); + + yield* validateCopilotReasoningEffort({ + selectedModel, + reasoningEffort: input.reasoningEffort, + onMissingModel: () => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: + "GitHub Copilot reasoning effort requires an explicit supported model selection.", + }), + onUnsupportedModel: (modelId) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${modelId}' does not support reasoning effort configuration.`, + }), + onUnsupportedReasoningEffort: (modelId, effort) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${modelId}' does not support reasoning effort '${effort}'.`, + }), + }); + }); + + const reconfigureSession = ( + record: ActiveCopilotSession, + input: { + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }, + ) => + Effect.tryPromise({ + try: async () => { + if ( + input.model && + (input.model !== record.model || input.reasoningEffort !== record.reasoningEffort) + ) { + await record.session.rpc.model.switchTo({ + modelId: input.model, + ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), + }); + } + record.model = input.model; + record.reasoningEffort = input.reasoningEffort; + record.updatedAt = new Date().toISOString(); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.model.switchTo", + detail: toMessage(cause, "Failed to reconfigure GitHub Copilot session."), + cause, + }), + }); + + const handleSessionEvent = (record: ActiveCopilotSession, event: SessionEvent) => { + record.updatedAt = event.timestamp; + if (event.type === "assistant.turn_start") { + beginCopilotTurn(record, TurnId.makeUnsafe(event.data.turnId)); + } + if (event.type === "assistant.usage") { + recordTurnUsage(record, event.data); + } + if (event.type === "session.error") { + record.lastError = event.data.message; + } + if (event.type === "session.model_change") { + record.model = event.data.newModel; + } + if (event.type === "session.mode_changed") { + record.interactionMode = toInteractionMode(event.data.newMode); + } + if (event.type === "tool.execution_start") { + const itemType = itemTypeFromToolEvent(event); + record.toolItemTypesByCallId.set(event.data.toolCallId, itemType); + record.toolTitlesByCallId.set( + event.data.toolCallId, + toolTitleFromItemType(itemType, event.data.toolName), + ); + const toolDetail = toolDetailFromEvent(event.data); + if (toolDetail) { + record.toolDetailsByCallId.set(event.data.toolCallId, toolDetail); + } + } + + void writeNativeEvent(record.threadId, event); + const runtimeEvents = mapSessionEvent(record, event); + if (runtimeEvents.length > 0) { + void emitRuntimeEvents(runtimeEvents); + } + if (event.type === "session.plan_changed" && event.data.operation !== "delete") { + void Effect.runPromise(emitLatestProposedPlan(record)).catch((cause) => { + void emitRuntimeEvents([ + makeSyntheticEvent( + record.threadId, + "runtime.warning", + { + message: "Failed to read GitHub Copilot plan.", + detail: toMessage(cause, "Failed to read GitHub Copilot plan."), + }, + { turnId: currentSyntheticTurnId(record) }, + ), + ]); + }); + } + if (event.type === "tool.execution_complete") { + record.toolItemTypesByCallId.delete(event.data.toolCallId); + record.toolTitlesByCallId.delete(event.data.toolCallId); + record.toolDetailsByCallId.delete(event.data.toolCallId); + } + if (event.type === "assistant.turn_end") { + markTurnAwaitingCompletion(record); + } + if (event.type === "abort" || event.type === "session.idle") { + clearTurnTracking(record); + } + }; + + const getSessionRecord = (threadId: ThreadId) => { + const record = sessions.get(threadId); + if (!record) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(record); + }; + + const stopRecord = async (record: ActiveCopilotSession) => { + record.unsubscribe(); + try { + await closeCopilotSession(record.session); + } catch { + // best effort + } + try { + await Effect.runPromise(Scope.close(record.sessionScope, Exit.void)); + } catch { + // best effort + } + for (const pending of record.pendingApprovalResolvers.values()) { + pending.resolve({ kind: "denied-interactively-by-user" }); + } + record.pendingApprovalResolvers.clear(); + for (const pending of record.pendingUserInputResolvers.values()) { + pending.resolve({ answer: "", wasFreeform: true }); + } + record.pendingUserInputResolvers.clear(); + sessions.delete(record.threadId); + }; + + const startSession: CopilotAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}', received '${input.provider}'.`, + }); + } + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected modelSelection.provider '${PROVIDER}', received '${input.modelSelection.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + return { + provider: PROVIDER, + status: "ready", + runtimeMode: existing.runtimeMode, + ...(existing.cwd ? { cwd: existing.cwd } : {}), + ...(existing.model ? { model: existing.model } : {}), + threadId: input.threadId, + resumeCursor: existing.session.sessionId, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + ...(existing.lastError ? { lastError: existing.lastError } : {}), + } satisfies ProviderSession; + } + + const copilotSettings = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.copilot), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to read GitHub Copilot server settings."), + cause, + }), + ), + ); + const { clientOptions, configDir } = resolveCopilotRuntimeConfig( + copilotSettings, + input.cwd, + ); + const resumeSessionId = extractResumeSessionId(input.resumeCursor); + const pendingApprovalResolvers = new Map(); + const pendingUserInputResolvers = new Map(); + const modelSelection = getCopilotModelSelection(input); + const selectedModel = modelSelection?.model; + const reasoningEffort = getCopilotReasoningEffort(modelSelection); + let sessionRecord: ActiveCopilotSession | undefined; + const handlers = createInteractionHandlers( + input.threadId, + () => sessionRecord?.currentTurnId, + () => sessionRecord?.runtimeMode ?? input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + ); + const { client, session, sessionScope } = yield* Effect.acquireUseRelease( + Scope.make("sequential"), + (sessionScope) => + Effect.gen(function* () { + const client = yield* Effect.acquireRelease( + Effect.sync( + () => options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions), + ), + (client) => stopCopilotClient(client), + ).pipe(Scope.provide(sessionScope)); + + yield* validateSessionConfiguration({ + client, + threadId: input.threadId, + model: selectedModel, + reasoningEffort, + }); + + const session = yield* Effect.tryPromise({ + try: async () => { + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { + ...handlers, + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + } + const sessionConfig: Parameters[0] & { + sessionId?: string; + } = { + ...handlers, + sessionId: makeCopilotSessionId(input.threadId), + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }; + return client.createSession(sessionConfig); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot session."), + cause, + }), + }); + + return { client, session, sessionScope } as const; + }), + (sessionScope, exit) => + Exit.isFailure(exit) ? Scope.close(sessionScope, exit) : Effect.void, + ); + + const record = createSessionRecord({ + threadId: input.threadId, + client, + sessionScope, + session, + runtimeMode: input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + cwd: input.cwd, + configDir, + model: selectedModel, + reasoningEffort, + }); + const unsubscribe = session.on((event) => { + handleSessionEvent(record, event); + }); + record.unsubscribe = unsubscribe; + sessionRecord = record; + sessions.set(input.threadId, record); + + yield* Queue.offerAll(runtimeEventQueue, [ + makeSyntheticEvent(input.threadId, "session.started", { + message: resumeSessionId + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: { sessionId: session.sessionId }, + }), + makeSyntheticEvent(input.threadId, "session.configured", { + config: { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }, + }), + makeSyntheticEvent(input.threadId, "thread.started", { + providerThreadId: session.sessionId, + }), + makeSyntheticEvent(input.threadId, "session.state.changed", { + state: "ready", + reason: "session.started", + }), + ]); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + threadId: input.threadId, + resumeCursor: session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession; + }); + + const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const record = yield* getSessionRecord(input.threadId); + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Expected modelSelection.provider '${PROVIDER}', received '${input.modelSelection.provider}'.`, + }); + } + + const modelSelection = getCopilotModelSelection(input); + const explicitReasoningEffort = getCopilotReasoningEffort(modelSelection); + const nextModel = modelSelection?.model ?? record.model; + const nextReasoningEffort = + explicitReasoningEffort !== undefined + ? explicitReasoningEffort + : modelSelection?.model && modelSelection.model !== record.model + ? undefined + : record.reasoningEffort; + const shouldReconfigure = + nextModel !== record.model || nextReasoningEffort !== record.reasoningEffort; + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: attachmentPath, + displayName: attachment.name, + }); + }); + + if (shouldReconfigure) { + yield* validateSessionConfiguration({ + client: record.client, + threadId: input.threadId, + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + yield* reconfigureSession(record, { + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + } + + const interactionMode = input.interactionMode ?? record.interactionMode ?? "default"; + yield* syncInteractionMode(record, interactionMode); + + const turnId = TurnId.makeUnsafe(`copilot-turn-${randomUUID()}`); + record.pendingTurnIds.push(turnId); + record.currentTurnId = turnId; + record.currentProviderTurnId = undefined; + + yield* Effect.tryPromise({ + try: () => + record.session.send({ + prompt: input.input ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: toMessage(cause, "Failed to send GitHub Copilot turn."), + cause, + }), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + record.pendingTurnIds = record.pendingTurnIds.filter( + (candidate) => candidate !== turnId, + ); + if (record.currentTurnId === turnId) { + record.currentTurnId = undefined; + } + }), + ), + ); + + record.updatedAt = new Date().toISOString(); + + return { + threadId: input.threadId, + turnId, + resumeCursor: record.session.sessionId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: () => record.session.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: toMessage(cause, "Failed to interrupt GitHub Copilot turn."), + cause, + }), + }); + }); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingApprovalResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.permission.respond", + detail: `Unknown pending GitHub Copilot approval request '${requestId}'.`, + }); + } + record.pendingApprovalResolvers.delete(requestId); + pending.resolve(approvalDecisionToPermissionResult(decision)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "request.resolved", + { + requestType: pending.requestType, + decision, + resolution: approvalDecisionToPermissionResult(decision), + }, + { requestId, turnId: pending.turnId }, + ), + ); + }); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingUserInputResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.userInput.respond", + detail: `Unknown pending GitHub Copilot user-input request '${requestId}'.`, + }); + } + record.pendingUserInputResolvers.delete(requestId); + pending.resolve(resolveUserInputAnswer(pending, answers)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "user-input.resolved", + { + answers, + }, + { requestId, turnId: pending.turnId }, + ), + ); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: async () => { + await stopRecord(record); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to stop GitHub Copilot session."), + cause, + }), + }); + }); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map((record) => + Object.assign( + { + provider: PROVIDER, + status: record.currentTurnId ? "running" : "ready", + runtimeMode: record.runtimeMode, + threadId: record.threadId, + resumeCursor: record.session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession, + record.cwd ? { cwd: record.cwd } : undefined, + record.model ? { model: record.model } : undefined, + record.currentTurnId ? { activeTurnId: record.currentTurnId } : undefined, + record.lastError ? { lastError: record.lastError } : undefined, + ), + ), + ); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + return yield* Effect.tryPromise({ + try: async () => { + const messages = await record.session.getMessages(); + return mapHistoryToTurns(threadId, messages); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.getMessages", + detail: toMessage(cause, "Failed to read GitHub Copilot thread history."), + cause, + }), + }); + }); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = (_threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: + "GitHub Copilot SDK does not expose a supported conversation rollback API for existing sessions.", + }), + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + await Promise.all(Array.from(sessions.values()).map((record) => stopRecord(record))); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: ThreadId.makeUnsafe("_all"), + detail: toMessage(cause, "Failed to stop GitHub Copilot sessions."), + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CopilotAdapterShape; + }); + +export const CopilotAdapterLive = Layer.effect(CopilotAdapter, makeCopilotAdapter()); + +export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { + return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts new file mode 100644 index 000000000..c6255277b --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,227 @@ +import type { + CopilotSettings, + ServerProvider, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { CopilotClient, type CopilotClientOptions } from "@github/copilot-sdk"; +import { Data, Effect, Equal, Layer, Result, Stream } from "effect"; +import { COPILOT_BUILT_IN_MODELS } from "@t3tools/shared/copilot"; + +import { + buildServerProvider, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + providerModelsFromSettings, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { CopilotProvider } from "../Services/CopilotProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; +import { resolveCopilotRuntimeConfig } from "./copilotSdk"; + +const PROVIDER = "copilot" as const; +class CopilotProviderProbeError extends Data.TaggedError("CopilotProviderProbeError")<{ + cause: unknown; +}> {} +class CopilotProviderCommandMissingError extends Data.TaggedError( + "CopilotProviderCommandMissingError", +)<{ + cause: unknown; +}> {} +class CopilotProviderTimeoutError extends Data.TaggedError("CopilotProviderTimeoutError") {} +const COPILOT_PROVIDER_TIMEOUT_CODE = "COPILOT_PROVIDER_TIMEOUT"; +const COPILOT_PROVIDER_TIMEOUT_MESSAGE = + "GitHub Copilot CLI health check timed out while starting the SDK client."; + +const toProbeError = (cause: unknown): CopilotProviderProbeError => + new CopilotProviderProbeError({ cause }); +const toCommandMissingError = (cause: unknown): CopilotProviderCommandMissingError => + new CopilotProviderCommandMissingError({ cause }); +const toTimeoutError = (): CopilotProviderTimeoutError => new CopilotProviderTimeoutError(); + +const isCopilotProviderProbeTimeoutError = (cause: unknown): cause is Error & { code: string } => + cause instanceof Error && "code" in cause && cause.code === COPILOT_PROVIDER_TIMEOUT_CODE; + +interface CopilotProviderStatusClientHandle { + start(): Promise; + getStatus(): Promise<{ readonly version?: string | null } | undefined>; + getAuthStatus(): Promise< + | { + readonly isAuthenticated?: boolean; + readonly statusMessage?: string; + } + | undefined + >; + stop(): Promise>; +} + +export interface CheckCopilotProviderStatusOptions { + readonly clientFactory?: (options: CopilotClientOptions) => CopilotProviderStatusClientHandle; + readonly timeoutMs?: number; +} + +export const makeCheckCopilotProviderStatus = (options?: CheckCopilotProviderStatusOptions) => + Effect.fn("checkCopilotProviderStatus")(function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ServerSettingsService + > { + const copilotSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.copilot), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + COPILOT_BUILT_IN_MODELS, + PROVIDER, + copilotSettings.customModels, + ); + + if (!copilotSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "GitHub Copilot is disabled in T3 Code settings.", + }, + }); + } + + const { clientOptions } = resolveCopilotRuntimeConfig(copilotSettings, undefined); + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const probe = yield* Effect.result( + Effect.tryPromise({ + try: async () => { + const client = + options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions); + + try { + return await Promise.race([ + (async () => { + await client.start(); + const [status, authStatus] = await Promise.all([ + client.getStatus(), + client.getAuthStatus().catch(() => undefined), + ]); + return { status, authStatus }; + })(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + Object.assign(new Error(COPILOT_PROVIDER_TIMEOUT_MESSAGE), { + code: COPILOT_PROVIDER_TIMEOUT_CODE, + }), + ); + }, timeoutMs); + }), + ]); + } finally { + await client.stop().catch(() => undefined); + } + }, + catch: (cause) => + isCopilotProviderProbeTimeoutError(cause) + ? toTimeoutError() + : isCommandMissingCause(cause) + ? toCommandMissingError(cause) + : toProbeError(cause), + }), + ); + + if (Result.isFailure(probe)) { + const error = probe.failure; + if ( + error instanceof CopilotProviderTimeoutError || + (error instanceof CopilotProviderProbeError && + isCopilotProviderProbeTimeoutError(error.cause)) + ) { + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: COPILOT_PROVIDER_TIMEOUT_MESSAGE, + }, + }); + } + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: !(error instanceof CopilotProviderCommandMissingError), + version: null, + status: "error", + authStatus: "unknown", + message: + error instanceof CopilotProviderCommandMissingError + ? "GitHub Copilot CLI is not installed or could not be resolved." + : `Failed to start GitHub Copilot CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + const authStatus: ServerProviderAuthStatus = + probe.success.authStatus?.isAuthenticated === true + ? "authenticated" + : probe.success.authStatus?.isAuthenticated === false + ? "unauthenticated" + : "unknown"; + const status: Exclude = + authStatus === "unauthenticated" ? "error" : authStatus === "unknown" ? "warning" : "ready"; + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: probe.success.status?.version ?? null, + status, + authStatus, + ...(probe.success.authStatus?.statusMessage + ? { message: probe.success.authStatus.statusMessage } + : probe.success.status?.version + ? { message: `GitHub Copilot CLI ${probe.success.status.version}` } + : {}), + }, + }); + }); + +export const checkCopilotProviderStatus = makeCheckCopilotProviderStatus(); + +export const CopilotProviderLive = Layer.effect( + CopilotProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const checkProvider = checkCopilotProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.copilot), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.copilot), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0f..ed1a5bb65 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter, CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeCopilotAdapter: CopilotAdapterShape = { + provider: "copilot", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(CopilotAdapter, fakeCopilotAdapter), ), ), NodeServices.layer, @@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const copilot = yield* registry.getByProvider("copilot"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(copilot, fakeCopilotAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "copilot"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 23ef8d1b9..588c7d2ed 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -27,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CopilotAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bed25977d..56660a655 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -30,6 +30,7 @@ import { readCodexConfigModelProvider, } from "./CodexProvider"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; +import { makeCheckCopilotProviderStatus } from "./CopilotProvider"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; @@ -96,6 +97,29 @@ function failingSpawnerLayer(description: string) { ); } +class FakeCopilotProviderClient { + public startImpl = async () => undefined; + public getStatusImpl = async () => ({ version: "1.2.3" }); + public getAuthStatusImpl = async () => ({ isAuthenticated: true }); + public stopImpl = async () => [] as Error[]; + + start() { + return this.startImpl(); + } + + getStatus() { + return this.getStatusImpl(); + } + + getAuthStatus() { + return this.getAuthStatusImpl(); + } + + stop() { + return this.stopImpl(); + } +} + function makeMutableServerSettingsService( initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, ) { @@ -472,6 +496,136 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ); }); + describe("checkCopilotProviderStatus", () => { + it.effect("skips Copilot probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const checkCopilotProviderStatus = makeCheckCopilotProviderStatus({ + clientFactory: () => { + throw new Error("client factory should not be called when disabled"); + }, + }); + const status = yield* checkCopilotProviderStatus().pipe( + Effect.provide( + ServerSettingsService.layerTest({ + providers: { + copilot: { + enabled: false, + }, + }, + }), + ), + ); + + assert.strictEqual(status.provider, "copilot"); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.message, "GitHub Copilot is disabled in T3 Code settings."); + }), + ); + + it.effect("reports a missing Copilot CLI as not installed", () => + Effect.gen(function* () { + const client = new FakeCopilotProviderClient(); + client.startImpl = async () => { + throw new Error("spawn copilot ENOENT"); + }; + const checkCopilotProviderStatus = makeCheckCopilotProviderStatus({ + clientFactory: () => client, + }); + const status = yield* checkCopilotProviderStatus(); + + assert.strictEqual(status.provider, "copilot"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "GitHub Copilot CLI is not installed or could not be resolved.", + ); + }), + ); + + it.effect("reports Copilot startup timeouts predictably", () => + Effect.gen(function* () { + const client = new FakeCopilotProviderClient(); + client.startImpl = () => new Promise(() => undefined); + const checkCopilotProviderStatus = makeCheckCopilotProviderStatus({ + clientFactory: () => client, + timeoutMs: 1, + }); + const status = yield* checkCopilotProviderStatus(); + + assert.strictEqual(status.provider, "copilot"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "GitHub Copilot CLI health check timed out while starting the SDK client.", + ); + }), + ); + + it.effect("returns ready when Copilot is installed and authenticated", () => + Effect.gen(function* () { + const client = new FakeCopilotProviderClient(); + let lastClientOptions: unknown; + const checkCopilotProviderStatus = makeCheckCopilotProviderStatus({ + clientFactory: (options) => { + lastClientOptions = options; + return client; + }, + }); + const status = yield* checkCopilotProviderStatus().pipe( + Effect.provide( + ServerSettingsService.layerTest({ + providers: { + copilot: { + binaryPath: "/tmp/copilot", + configDir: "/tmp/copilot-config", + customModels: ["custom-copilot-model"], + }, + }, + }), + ), + ); + + assert.strictEqual(status.provider, "copilot"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.version, "1.2.3"); + assert.deepStrictEqual(lastClientOptions, { + cliPath: "/tmp/copilot", + logLevel: "error", + }); + assert.strictEqual( + status.models.some((model) => model.slug === "custom-copilot-model"), + true, + ); + }), + ); + + it.effect("returns unauthenticated when Copilot auth status says login is required", () => + Effect.gen(function* () { + const client = new FakeCopilotProviderClient(); + client.getAuthStatusImpl = async () => ({ + isAuthenticated: false, + statusMessage: "Run `github-copilot auth login` to continue.", + }); + const checkCopilotProviderStatus = makeCheckCopilotProviderStatus({ + clientFactory: () => client, + }); + const status = yield* checkCopilotProviderStatus(); + + assert.strictEqual(status.provider, "copilot"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.message, "Run `github-copilot auth login` to continue."); + }), + ); + }); + // ── Custom model provider: checkCodexProviderStatus integration ─── describe("checkCodexProviderStatus with custom model provider", () => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1e66ce8ff..1344aa681 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -7,9 +7,12 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; +import { CopilotProviderLive } from "./CopilotProvider"; import { CodexProviderLive } from "./CodexProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; +import type { CopilotProviderShape } from "../Services/CopilotProvider"; +import { CopilotProvider } from "../Services/CopilotProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; @@ -17,8 +20,9 @@ import { ProviderRegistry, type ProviderRegistryShape } from "../Services/Provid const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + copilotProvider: CopilotProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, copilotProvider.getSnapshot], { concurrency: "unbounded", }); @@ -32,18 +36,19 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const copilotProvider = yield* CopilotProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(codexProvider, claudeProvider, copilotProvider), ); const syncProviders = (options?: { readonly publish?: boolean }) => Effect.gen(function* () { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders(codexProvider, claudeProvider, copilotProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -59,6 +64,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(copilotProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); return { getProviders: syncProviders({ publish: false }).pipe( @@ -74,10 +82,16 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "copilot": + yield* copilotProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, copilotProvider.refresh], + { + concurrency: "unbounded", + }, + ); break; } return yield* syncProviders(); @@ -90,4 +104,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(CopilotProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index d23b247f2..230024c14 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -202,6 +202,52 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(legacyTableRows.length, 0); }).pipe(Effect.provide(directoryLayer)); + fs.rmSync(tempDir, { recursive: true, force: true }); + })); + + it("persists Copilot provider bindings across restart", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-copilot-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); + const threadId = ThreadId.makeUnsafe("thread-copilot-restart"); + + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + yield* directory.upsert({ + provider: "copilot", + threadId, + status: "running", + resumeCursor: { + sessionId: "copilot-session-1", + }, + runtimePayload: { + model: "gpt-5.4", + reasoningEffort: "high", + }, + }); + }).pipe(Effect.provide(directoryLayer)); + + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* directory.getProvider(threadId); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + + assert.equal(provider, "copilot"); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "copilot"); + assert.deepEqual(runtime.value.resumeCursor, { + sessionId: "copilot-session-1", + }); + assert.deepEqual(runtime.value.runtimePayload, { + model: "gpt-5.4", + reasoningEffort: "high", + }); + } + }).pipe(Effect.provide(directoryLayer)); + fs.rmSync(tempDir, { recursive: true, force: true }); })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d69..a24e933e0 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "copilot") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Layers/copilotCliPath.test.ts b/apps/server/src/provider/Layers/copilotCliPath.test.ts new file mode 100644 index 000000000..7aa229752 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotCliPath.test.ts @@ -0,0 +1,102 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveBundledCopilotCliPathFrom } from "./copilotCliPath.ts"; + +const CURRENT_DIR = "/repo/apps/server/src/provider/Layers"; +const SDK_ENTRYPOINT = "/repo/apps/server/node_modules/@github/copilot-sdk/dist/index.js"; + +describe("copilotCliPath", () => { + it("prefers the native binary on Windows", () => { + const npmLoaderPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot", + "npm-loader.js", + ); + const binaryPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot-win32-x64", + "copilot.exe", + ); + const existingPaths = new Set([npmLoaderPath, binaryPath]); + + expect( + resolveBundledCopilotCliPathFrom({ + currentDir: CURRENT_DIR, + sdkEntrypoint: SDK_ENTRYPOINT, + platform: "win32", + arch: "x64", + exists: (candidate) => existingPaths.has(candidate), + }), + ).toBe(binaryPath); + }); + + it("keeps the native binary preference on non-Windows platforms", () => { + const npmLoaderPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot", + "npm-loader.js", + ); + const binaryPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot-linux-x64", + "copilot", + ); + const existingPaths = new Set([npmLoaderPath, binaryPath]); + + expect( + resolveBundledCopilotCliPathFrom({ + currentDir: CURRENT_DIR, + sdkEntrypoint: SDK_ENTRYPOINT, + platform: "linux", + arch: "x64", + exists: (candidate) => existingPaths.has(candidate), + }), + ).toBe(binaryPath); + }); + + it("falls back to npm-loader.js when no native binary is present on Windows", () => { + const npmLoaderPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot", + "npm-loader.js", + ); + const existingPaths = new Set([npmLoaderPath]); + + expect( + resolveBundledCopilotCliPathFrom({ + currentDir: CURRENT_DIR, + sdkEntrypoint: SDK_ENTRYPOINT, + platform: "win32", + arch: "x64", + exists: (candidate) => existingPaths.has(candidate), + }), + ).toBe(npmLoaderPath); + }); + + it("falls back to npm-loader.js when no native binary is present on non-Windows platforms", () => { + const npmLoaderPath = join( + "/repo/apps/server/node_modules", + "@github", + "copilot", + "npm-loader.js", + ); + const existingPaths = new Set([npmLoaderPath]); + + expect( + resolveBundledCopilotCliPathFrom({ + currentDir: CURRENT_DIR, + sdkEntrypoint: SDK_ENTRYPOINT, + platform: "darwin", + arch: "arm64", + exists: (candidate) => existingPaths.has(candidate), + }), + ).toBe(npmLoaderPath); + }); +}); diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts new file mode 100644 index 000000000..dce3dc77b --- /dev/null +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -0,0 +1,176 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +const GITHUB_SCOPE_DIR = "@github"; +const COPILOT_NPM_LOADER = "npm-loader.js"; +const COPILOT_PATHLESS_COMMAND_PATTERN = /^copilot(?:\.(?:exe|cmd|bat))?$/i; + +function dedupePaths(paths: ReadonlyArray): string[] { + const resolved: string[] = []; + const seen = new Set(); + + for (const candidate of paths) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + resolved.push(candidate); + } + + return resolved; +} + +function resolveSdkEntrypoint(): string | undefined { + try { + return require.resolve("@github/copilot-sdk"); + } catch { + return undefined; + } +} + +function resolveProcessResourcesPath(): string | undefined { + const processWithResourcesPath = process as NodeJS.Process & { + readonly resourcesPath?: string; + }; + return processWithResourcesPath.resourcesPath; +} + +function resolveGithubScopeDirFromSdkEntrypoint( + sdkEntrypoint: string | undefined, +): string | undefined { + if (!sdkEntrypoint) { + return undefined; + } + return join(dirname(dirname(sdkEntrypoint)), ".."); +} + +function resolveNodeModulesRoots(input: { + currentDir: string; + resourcesPath?: string; + sdkEntrypoint?: string; +}): string[] { + const githubScopeDir = resolveGithubScopeDirFromSdkEntrypoint(input.sdkEntrypoint); + return dedupePaths([ + input.resourcesPath ? join(input.resourcesPath, "app.asar.unpacked/node_modules") : undefined, + input.resourcesPath ? join(input.resourcesPath, "node_modules") : undefined, + join(input.currentDir, "../../../node_modules"), + join(input.currentDir, "../../../../../node_modules"), + githubScopeDir ? join(githubScopeDir, "..") : undefined, + ]); +} + +function getCopilotPlatformBinaryName(platform: string): string { + return platform === "win32" ? "copilot.exe" : "copilot"; +} + +export function normalizeCopilotCliPathOverride( + value: string | null | undefined, +): string | undefined { + if (value == null) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + !trimmed.includes("/") && + !trimmed.includes("\\") && + COPILOT_PATHLESS_COMMAND_PATTERN.test(trimmed) + ) { + return undefined; + } + + return trimmed; +} + +export function getBundledCopilotPlatformPackages( + platform: string = process.platform, + arch: string = process.arch, +): ReadonlyArray { + if (platform === "darwin" && arch === "arm64") { + return ["copilot-darwin-arm64"]; + } + if (platform === "darwin" && arch === "x64") { + return ["copilot-darwin-x64"]; + } + if (platform === "linux" && arch === "arm64") { + return ["copilot-linux-arm64"]; + } + if (platform === "linux" && arch === "x64") { + return ["copilot-linux-x64"]; + } + if (platform === "win32" && arch === "arm64") { + return ["copilot-win32-arm64"]; + } + if (platform === "win32" && arch === "x64") { + return ["copilot-win32-x64"]; + } + + return []; +} + +export function resolveBundledCopilotCliPathFrom(input: { + currentDir: string; + resourcesPath?: string; + sdkEntrypoint?: string; + platform?: string; + arch?: string; + exists?: (path: string) => boolean; +}): string | undefined { + const platform = input.platform ?? process.platform; + const arch = input.arch ?? process.arch; + const exists = input.exists ?? existsSync; + const nodeModulesRoots = resolveNodeModulesRoots({ + currentDir: input.currentDir, + ...(input.resourcesPath ? { resourcesPath: input.resourcesPath } : {}), + ...(input.sdkEntrypoint ? { sdkEntrypoint: input.sdkEntrypoint } : {}), + }); + const binaryName = getCopilotPlatformBinaryName(platform); + const platformPackages = getBundledCopilotPlatformPackages(platform, arch); + + const binaryCandidates = nodeModulesRoots.flatMap((root) => + platformPackages.map((packageName) => join(root, GITHUB_SCOPE_DIR, packageName, binaryName)), + ); + const npmLoaderCandidates = nodeModulesRoots.map((root) => + join(root, GITHUB_SCOPE_DIR, "copilot", COPILOT_NPM_LOADER), + ); + for (const candidate of dedupePaths([...binaryCandidates, ...npmLoaderCandidates])) { + if (exists(candidate)) { + return candidate; + } + } + + const githubScopeDir = resolveGithubScopeDirFromSdkEntrypoint(input.sdkEntrypoint); + if (!githubScopeDir) { + return undefined; + } + + const sdkSiblingBinaryCandidates = platformPackages.map((packageName) => + join(githubScopeDir, packageName, binaryName), + ); + const sdkSiblingLoaderPath = join(githubScopeDir, "copilot", COPILOT_NPM_LOADER); + for (const candidate of dedupePaths([...sdkSiblingBinaryCandidates, sdkSiblingLoaderPath])) { + if (exists(candidate)) { + return candidate; + } + } + + return undefined; +} + +export function resolveBundledCopilotCliPath(): string | undefined { + const sdkEntrypoint = resolveSdkEntrypoint(); + const resourcesPath = resolveProcessResourcesPath(); + return resolveBundledCopilotCliPathFrom({ + currentDir: CURRENT_DIR, + ...(resourcesPath ? { resourcesPath } : {}), + ...(sdkEntrypoint ? { sdkEntrypoint } : {}), + }); +} diff --git a/apps/server/src/provider/Layers/copilotSdk.ts b/apps/server/src/provider/Layers/copilotSdk.ts new file mode 100644 index 000000000..d4546ca4c --- /dev/null +++ b/apps/server/src/provider/Layers/copilotSdk.ts @@ -0,0 +1,160 @@ +import { Effect } from "effect"; +import type { ChatAttachment, CodexReasoningEffort, CopilotSettings } from "@t3tools/contracts"; +import type { CopilotClientOptions, ModelInfo } from "@github/copilot-sdk"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; + +export interface CopilotModelMetadataClient { + start(): Promise; + listModels(): Promise>; +} + +export interface StoppableCopilotClient { + stop(): Promise>; +} + +export interface CopilotFileAttachment { + readonly type: "file"; + readonly path: string; + readonly displayName?: string; +} + +export function trimToUndefined(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function resolveCopilotRuntimeConfig( + settings: Pick, + cwd: string | undefined, +): { + readonly clientOptions: CopilotClientOptions; + readonly configDir: string | undefined; +} { + const cliPath = + normalizeCopilotCliPathOverride(settings.binaryPath) ?? resolveBundledCopilotCliPath(); + return { + clientOptions: { + ...(cliPath ? { cliPath } : {}), + ...(cwd ? { cwd } : {}), + logLevel: "error", + }, + configDir: trimToUndefined(settings.configDir), + }; +} + +export function stopCopilotClient(client: StoppableCopilotClient): Effect.Effect { + return Effect.tryPromise({ + try: () => client.stop(), + catch: () => undefined, + }).pipe( + Effect.catch(() => Effect.void), + Effect.asVoid, + ); +} + +export function mapSupportedModelsById(models: ReadonlyArray) { + return new Map(models.map((model) => [model.id, model])); +} + +export function loadCopilotSupportedModels(input: { + readonly client: CopilotModelMetadataClient; + readonly onStartError: (cause: unknown) => E; + readonly onListError: (cause: unknown) => E; +}): Effect.Effect, E> { + return Effect.tryPromise({ + try: () => input.client.start(), + catch: input.onStartError, + }).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: () => input.client.listModels(), + catch: input.onListError, + }), + ), + Effect.map((models) => mapSupportedModelsById(models)), + ); +} + +export function resolveCopilotSelectedModel(input: { + readonly supportedModels: ReadonlyMap; + readonly model: string | undefined; + readonly onMissingModel: (model: string) => E; +}): Effect.Effect { + if (!input.model) { + return Effect.void as Effect.Effect; + } + const selectedModel = input.supportedModels.get(input.model); + if (!selectedModel) { + return Effect.fail(input.onMissingModel(input.model)); + } + return Effect.succeed(selectedModel); +} + +export function validateCopilotReasoningEffort(input: { + readonly selectedModel: ModelInfo | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + readonly onMissingModel: () => E; + readonly onUnsupportedModel: (modelId: string) => E; + readonly onUnsupportedReasoningEffort: (modelId: string, effort: CodexReasoningEffort) => E; +}): Effect.Effect { + if (!input.reasoningEffort) { + return Effect.void; + } + if (!input.selectedModel) { + return Effect.fail(input.onMissingModel()); + } + const supportedReasoningEfforts = input.selectedModel.supportedReasoningEfforts ?? []; + if (supportedReasoningEfforts.length === 0) { + return Effect.fail(input.onUnsupportedModel(input.selectedModel.id)); + } + if (!supportedReasoningEfforts.includes(input.reasoningEffort)) { + return Effect.fail( + input.onUnsupportedReasoningEffort(input.selectedModel.id, input.reasoningEffort), + ); + } + return Effect.void; +} + +export function selectCopilotReasoningEffort(input: { + readonly selectedModel: ModelInfo; + readonly explicitReasoningEffort: CodexReasoningEffort | undefined; + readonly fallbackReasoningEffort: CodexReasoningEffort; +}): CodexReasoningEffort | undefined { + if (input.explicitReasoningEffort) { + return input.explicitReasoningEffort; + } + return input.selectedModel.supportedReasoningEfforts?.includes(input.fallbackReasoningEffort) + ? input.fallbackReasoningEffort + : undefined; +} + +export function materializeCopilotAttachments( + attachmentsDir: string, + attachments: ReadonlyArray | undefined, +): Array { + if (!attachments || attachments.length === 0) { + return []; + } + + const results: Array = []; + for (const attachment of attachments) { + const resolvedPath = resolveAttachmentPath({ + attachmentsDir, + attachment, + }); + if (!resolvedPath) { + continue; + } + results.push({ + type: "file", + path: resolvedPath, + displayName: attachment.name, + }); + } + return results; +} diff --git a/apps/server/src/provider/Layers/copilotTurnTracking.test.ts b/apps/server/src/provider/Layers/copilotTurnTracking.test.ts new file mode 100644 index 000000000..004f9a011 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotTurnTracking.test.ts @@ -0,0 +1,60 @@ +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + assistantUsageFields, + beginCopilotTurn, + clearTurnTracking, + isCopilotTurnTerminalEvent, + markTurnAwaitingCompletion, + recordTurnUsage, + type CopilotTurnTrackingState, +} from "./copilotTurnTracking.ts"; + +function makeState(): CopilotTurnTrackingState { + return { + currentTurnId: undefined, + currentProviderTurnId: undefined, + pendingCompletionTurnId: undefined, + pendingCompletionProviderTurnId: undefined, + pendingTurnIds: [], + pendingTurnUsage: undefined, + }; +} + +describe("copilotTurnTracking", () => { + it("keeps turn tracking alive until session.idle", () => { + expect(isCopilotTurnTerminalEvent({ type: "assistant.usage" } as never)).toBe(false); + expect(isCopilotTurnTerminalEvent({ type: "session.idle" } as never)).toBe(true); + expect(isCopilotTurnTerminalEvent({ type: "abort" } as never)).toBe(true); + }); + + it("preserves usage details for the eventual turn completion event", () => { + const state = makeState(); + state.pendingTurnIds.push(TurnId.makeUnsafe("turn-1")); + + beginCopilotTurn(state, TurnId.makeUnsafe("provider-turn-1")); + recordTurnUsage(state, { + model: "gpt-4.1", + cost: 0.42, + totalTokens: 123, + } as never); + markTurnAwaitingCompletion(state); + + expect(assistantUsageFields(state.pendingTurnUsage)).toEqual({ + usage: { + model: "gpt-4.1", + cost: 0.42, + totalTokens: 123, + }, + modelUsage: { model: "gpt-4.1" }, + totalCostUsd: 0.42, + }); + + clearTurnTracking(state); + expect(state.pendingTurnUsage).toBeUndefined(); + expect(state.currentTurnId).toBeUndefined(); + expect(state.pendingCompletionTurnId).toBeUndefined(); + expect(state.pendingTurnIds).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Layers/copilotTurnTracking.ts b/apps/server/src/provider/Layers/copilotTurnTracking.ts new file mode 100644 index 000000000..ff2622858 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotTurnTracking.ts @@ -0,0 +1,70 @@ +import { TurnId } from "@t3tools/contracts"; +import type { SessionEvent } from "@github/copilot-sdk"; + +export type CopilotAssistantUsage = Extract["data"]; + +export interface CopilotTurnTrackingState { + currentTurnId: TurnId | undefined; + currentProviderTurnId: TurnId | undefined; + pendingCompletionTurnId: TurnId | undefined; + pendingCompletionProviderTurnId: TurnId | undefined; + pendingTurnIds: Array; + pendingTurnUsage: CopilotAssistantUsage | undefined; +} + +export function completionTurnRefs(state: CopilotTurnTrackingState) { + return { + turnId: state.pendingCompletionTurnId ?? state.currentTurnId, + providerTurnId: state.pendingCompletionProviderTurnId ?? state.currentProviderTurnId, + }; +} + +export function beginCopilotTurn(state: CopilotTurnTrackingState, providerTurnId: TurnId): void { + state.pendingCompletionTurnId = undefined; + state.pendingCompletionProviderTurnId = undefined; + state.pendingTurnUsage = undefined; + state.currentProviderTurnId = providerTurnId; + state.currentTurnId = state.pendingTurnIds.shift() ?? state.currentTurnId ?? providerTurnId; +} + +export function markTurnAwaitingCompletion(state: CopilotTurnTrackingState): void { + state.pendingCompletionTurnId = state.currentTurnId ?? state.pendingCompletionTurnId; + state.pendingCompletionProviderTurnId = + state.currentProviderTurnId ?? state.pendingCompletionProviderTurnId; +} + +export function recordTurnUsage( + state: CopilotTurnTrackingState, + usage: CopilotAssistantUsage, +): void { + state.pendingTurnUsage = usage; +} + +export function clearTurnTracking(state: CopilotTurnTrackingState): void { + state.currentTurnId = undefined; + state.currentProviderTurnId = undefined; + state.pendingCompletionTurnId = undefined; + state.pendingCompletionProviderTurnId = undefined; + state.pendingTurnIds = []; + state.pendingTurnUsage = undefined; +} + +export function assistantUsageFields(usage: CopilotAssistantUsage | undefined): { + usage?: CopilotAssistantUsage; + modelUsage?: { model: string }; + totalCostUsd?: number; +} { + if (!usage) { + return {}; + } + + return { + usage, + ...(usage.cost !== undefined ? { totalCostUsd: usage.cost } : {}), + ...(usage.model ? { modelUsage: { model: usage.model } } : {}), + }; +} + +export function isCopilotTurnTerminalEvent(event: SessionEvent): boolean { + return event.type === "abort" || event.type === "session.idle"; +} diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 000000000..4c8b99589 --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CopilotAdapterShape extends ProviderAdapterShape { + readonly provider: "copilot"; +} + +export class CopilotAdapter extends ServiceMap.Service()( + "t3/provider/Services/CopilotAdapter", +) {} diff --git a/apps/server/src/provider/Services/CopilotProvider.ts b/apps/server/src/provider/Services/CopilotProvider.ts new file mode 100644 index 000000000..7c7add463 --- /dev/null +++ b/apps/server/src/provider/Services/CopilotProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface CopilotProviderShape extends ServerProviderShape {} + +export class CopilotProvider extends ServiceMap.Service()( + "t3/provider/Services/CopilotProvider", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7..89ec67666 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -18,6 +18,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -78,9 +79,13 @@ export function makeServerProviderLayer(): Layer.Layer< const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const copilotAdapterLayer = makeCopilotAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(copilotAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f26fece24..cfa5b2c97 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -58,6 +58,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], }, + copilot: { + binaryPath: "/usr/local/bin/copilot", + configDir: "/Users/julius/.config/copilot", + customModels: ["copilot-custom"], + }, }, textGenerationModelSelection: { provider: "codex", @@ -93,6 +98,12 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], }); + assert.deepEqual(next.providers.copilot, { + enabled: true, + binaryPath: "/usr/local/bin/copilot", + configDir: "/Users/julius/.config/copilot", + customModels: ["copilot-custom"], + }); assert.deepEqual(next.textGenerationModelSelection, { provider: "codex", model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, @@ -117,6 +128,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + copilot: { + binaryPath: " /opt/homebrew/bin/copilot ", + configDir: " /Users/julius/.config/copilot ", + }, }, }); @@ -131,6 +146,12 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/claude", customModels: [], }); + assert.deepEqual(next.providers.copilot, { + enabled: true, + binaryPath: "/opt/homebrew/bin/copilot", + configDir: "/Users/julius/.config/copilot", + customModels: [], + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index f638e7fdf..242c35842 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -101,7 +101,7 @@ export class ServerSettingsService extends ServiceMap.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "copilot"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 849f59e08..2df3a0394 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,11 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + buildModelSelection, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -636,11 +640,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const selectedModelSelection = useMemo( - () => ({ - provider: selectedProvider, - model: selectedModel, - ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), - }), + () => buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedModelForPicker = selectedModel; @@ -1013,6 +1013,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + copilot: providerStatuses.find((provider) => provider.provider === "copilot")?.models ?? [], }), [providerStatuses], ); @@ -2568,14 +2569,15 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncateTitle(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; + const nextThreadModel = + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex; + const threadCreateModelSelection: ModelSelection = buildModelSelection( + selectedProvider, + nextThreadModel, + selectedModelSelection.options, + ); if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index e64a981ee..9ec46d2d7 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -63,6 +63,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + copilot: { enabled: true, binaryPath: "", configDir: "", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74..e9f8969bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,7 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { TimelineEntry } from "../../session-logic"; function matchMedia() { return { @@ -42,55 +43,59 @@ beforeAll(() => { }); }); +async function renderTimelineMarkup(timelineEntries: TimelineEntry[]) { + const { MessagesTimeline } = await import("./MessagesTimeline"); + return renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimelineMarkup([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]); expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); @@ -98,46 +103,84 @@ describe("MessagesTimeline", () => { }); it("renders context compaction entries in the normal work log", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimelineMarkup([ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ]); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("uses the activity label as the icon fallback when toolTitle is absent", async () => { + const markup = await renderTimelineMarkup([ + { + id: "entry-read-no-title", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-read-no-title", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Read file", + itemType: "dynamic_tool_call", + detail: + "/Users/zortos/t3code/apps/server/src/orchestration/Services/OrchestrationEngine.ts", + tone: "tool", + }, + }, + { + id: "entry-glob-no-title", + kind: "work", + createdAt: "2026-03-17T19:12:29.000Z", + entry: { + id: "work-glob-no-title", + createdAt: "2026-03-17T19:12:29.000Z", + label: "Glob", + itemType: "dynamic_tool_call", + detail: "/Users/zortos/t3code/apps/server/src", + tone: "tool", + }, + }, + ]); + + expect(markup).toContain("Read file"); + expect(markup).toContain("Glob"); + expect(markup).toContain("lucide-eye"); + expect(markup).toContain("lucide-search"); + expect(markup).not.toContain("lucide-hammer"); + }); + + it("prefers changed file paths over raw diff text in file change rows", async () => { + const markup = await renderTimelineMarkup([ + { + id: "entry-file-change", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-file-change", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Tool call", + toolTitle: "File change", + itemType: "file_change", + detail: "diff --git a/TESTING.md b/TESTING.md\n--- a/dev/null\n+++ b/TESTING.md", + changedFiles: ["/Users/zortos/t3code/TESTING.md"], + tone: "tool", + }, + }, + ]); + + expect(markup).toContain("File change"); + expect(markup).toContain("/Users/zortos/t3code/TESTING.md"); + expect(markup).not.toContain("diff --git"); + expect(markup).toContain("lucide-square-pen"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030e..7084790b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -27,6 +27,7 @@ import { GlobeIcon, HammerIcon, type LucideIcon, + SearchIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -801,18 +802,31 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { } function workEntryPreview( - workEntry: Pick, + workEntry: Pick< + TimelineWorkEntry, + "detail" | "command" | "changedFiles" | "itemType" | "toolTitle" | "label" + >, ) { if (workEntry.command) return workEntry.command; - if (workEntry.detail) return workEntry.detail; - if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; + const workEntryTitle = workEntryTitleKey(workEntry); + if (firstPath && (workEntry.itemType === "file_change" || workEntryTitle === "file change")) { + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + } + if (workEntry.detail) return workEntry.detail; if (!firstPath) return null; return workEntry.changedFiles!.length === 1 ? firstPath : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; } +function workEntryTitleKey(workEntry: Pick): string { + const title = workEntry.toolTitle ?? workEntry.label; + return title.trim().toLowerCase(); +} + function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; @@ -827,6 +841,22 @@ function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.itemType === "web_search") return GlobeIcon; if (workEntry.itemType === "image_view") return EyeIcon; + const toolTitle = workEntryTitleKey(workEntry); + if (toolTitle === "read file" || toolTitle === "view file" || toolTitle === "image view") { + return EyeIcon; + } + if ( + toolTitle === "glob" || + toolTitle === "grep" || + toolTitle === "search files" || + toolTitle === "list directory" + ) { + return SearchIcon; + } + if (toolTitle === "file change") { + return SquarePenIcon; + } + switch (workEntry.itemType) { case "mcp_tool_call": return WrenchIcon; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5a09defc7..96f8b27f8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,7 +18,7 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, GitHubIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { getProviderSnapshot } from "../../providerModels"; @@ -33,6 +33,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, + copilot: GitHubIcon, cursor: CursorIcon, }; @@ -47,7 +48,13 @@ function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; + if (provider === "claudeAgent") { + return "text-[#d97757]"; + } + if (provider === "copilot") { + return "text-foreground"; + } + return fallbackClassName; } export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -55,6 +62,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; + allowedProviders?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -65,6 +73,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const allowedProviders = props.allowedProviders ?? ["codex", "claudeAgent", "copilot"]; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; @@ -145,7 +154,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {AVAILABLE_PROVIDER_OPTIONS.filter((option) => + allowedProviders.includes(option.value), + ).map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5fd97b8cd..8fec3d55c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,5 +1,6 @@ import { type ClaudeModelOptions, + type CopilotModelOptions, type CodexModelOptions, type ProviderKind, type ProviderModelOptions, @@ -47,7 +48,7 @@ function getRawEffort( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, ): string | null { - if (provider === "codex") { + if (provider === "codex" || provider === "copilot") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); @@ -61,6 +62,12 @@ function buildNextOptions( if (provider === "codex") { return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; } + if (provider === "copilot") { + return { + ...(modelOptions as CopilotModelOptions | undefined), + ...patch, + } as CopilotModelOptions; + } return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; } @@ -177,7 +184,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange(nextPrompt); return; } - const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + const effortKey = provider === "claudeAgent" ? "effort" : "reasoningEffort"; updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), ); @@ -300,7 +307,7 @@ export const TraitsPicker = memo(function TraitsPicker({ .filter(Boolean) .join(" · "); - const isCodexStyle = provider === "codex"; + const isCodexStyle = provider !== "claudeAgent"; return ( = { /> ), }, + copilot: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b68663a89..ac56afb6f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -78,7 +78,7 @@ function resetComposerDraftStore() { } function modelSelection( - provider: "codex" | "claudeAgent", + provider: "codex" | "claudeAgent" | "copilot", model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -656,6 +656,25 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(modelSelection("codex", "gpt-5.4")); }); + it("stores Copilot reasoning effort without dropping the provider selection", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection( + threadId, + modelSelection("copilot", "gpt-5.4-mini", { + reasoningEffort: "medium", + }), + ); + + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.copilot, + ).toEqual( + modelSelection("copilot", "gpt-5.4-mini", { + reasoningEffort: "medium", + }), + ); + }); + it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); @@ -933,6 +952,27 @@ describe("composerDraftStore sticky composer settings", () => { activeProvider: "claudeAgent", }); }); + + it("applies sticky Copilot selections to new drafts", () => { + const store = useComposerDraftStore.getState(); + const threadId = ThreadId.makeUnsafe("thread-sticky-copilot"); + + store.setStickyModelSelection( + modelSelection("copilot", "gpt-5.4", { + reasoningEffort: "high", + }), + ); + store.applyStickyState(threadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelectionByProvider: { + copilot: modelSelection("copilot", "gpt-5.4", { + reasoningEffort: "high", + }), + }, + activeProvider: "copilot", + }); + }); }); describe("composerDraftStore provider-scoped option updates", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3d54c526f..1f8f32f90 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,7 +15,7 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; +import { buildModelSelection, getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -407,7 +407,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" ? value : null; + return value === "codex" || value === "claudeAgent" || value === "copilot" ? value : null; } function normalizeProviderModelOptions( @@ -424,6 +424,10 @@ function normalizeProviderModelOptions( candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record) : null; + const copilotCandidate = + candidate?.copilot && typeof candidate.copilot === "object" + ? (candidate.copilot as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -484,12 +488,27 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const copilotReasoningEffort: CodexReasoningEffort | undefined = + copilotCandidate?.reasoningEffort === "low" || + copilotCandidate?.reasoningEffort === "medium" || + copilotCandidate?.reasoningEffort === "high" || + copilotCandidate?.reasoningEffort === "xhigh" + ? copilotCandidate.reasoningEffort + : undefined; + const copilot = + copilotReasoningEffort !== undefined + ? { + reasoningEffort: copilotReasoningEffort, + } + : undefined; + + if (!codex && !claude && !copilot) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(copilot ? { copilot } : {}), }; } @@ -520,12 +539,13 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; - return { - provider, - model, - ...(options ? { options } : {}), - }; + const options = + provider === "codex" + ? modelOptions?.codex + : provider === "claudeAgent" + ? modelOptions?.claudeAgent + : modelOptions?.copilot; + return buildModelSelection(provider, model, options); } // ── Legacy sync helpers (used only during migration from v2 storage) ── @@ -538,11 +558,7 @@ function legacySyncModelSelectionOptions( return null; } const options = modelOptions?.[modelSelection.provider]; - return { - provider: modelSelection.provider, - model: modelSelection.model, - ...(options ? { options } : {}), - }; + return buildModelSelection(modelSelection.provider, modelSelection.model, options); } function legacyMergeModelSelectionIntoProviderModelOptions( @@ -586,17 +602,14 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "copilot"] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = { + result[provider] = buildModelSelection( provider, - model: - modelSelection?.provider === provider - ? modelSelection.model - : getDefaultModel(provider), + modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider), options, - }; + ); } } } @@ -1629,9 +1642,7 @@ export const useComposerDraftStore = create()( } else { // No options in selection → preserve existing options, update provider+model nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), + ...buildModelSelection(normalized.provider, normalized.model, current?.options), }; } } @@ -1668,17 +1679,17 @@ export const useComposerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "copilot"] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { - nextMap[provider] = { + nextMap[provider] = buildModelSelection( provider, - model: current?.model ?? getDefaultModel(provider), - options: opts, - }; + current?.model ?? getDefaultModel(provider), + opts, + ); } else if (current?.options) { // Remove options but keep the selection const { options: _, ...rest } = current; @@ -1724,11 +1735,11 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), - options: providerOpts, - }; + nextMap[normalizedProvider] = buildModelSelection( + normalizedProvider, + currentForProvider?.model ?? getDefaultModel(normalizedProvider), + providerOpts, + ); } else if (currentForProvider?.options) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; @@ -1742,16 +1753,13 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: getDefaultModel(normalizedProvider), - } as ModelSelection); + buildModelSelection(normalizedProvider, getDefaultModel(normalizedProvider)); if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, - provider: normalizedProvider, - options: providerOpts, - }; + nextStickyMap[normalizedProvider] = buildModelSelection( + normalizedProvider, + stickyBase.model, + providerOpts, + ); } else if (stickyBase.options) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts new file mode 100644 index 000000000..3875b6c2f --- /dev/null +++ b/apps/web/src/hooks/useSettings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { buildLegacyServerSettingsMigrationPatch } from "./useSettings"; + +describe("buildLegacyServerSettingsMigrationPatch", () => { + it("migrates Copilot settings and model selections from legacy local storage", () => { + const patch = buildLegacyServerSettingsMigrationPatch({ + copilotCliPath: " /opt/homebrew/bin/copilot ", + copilotConfigDir: " /Users/julius/.config/copilot ", + customCopilotModels: ["5.4", " custom-copilot "], + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5.4", + options: { + reasoningEffort: "medium", + }, + }, + }); + + expect(patch).toEqual({ + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5.4", + options: { + reasoningEffort: "medium", + }, + }, + providers: { + copilot: { + binaryPath: " /opt/homebrew/bin/copilot ", + configDir: " /Users/julius/.config/copilot ", + customModels: ["gpt-5.4", "custom-copilot"], + }, + }, + }); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index addf550e3..837e8b383 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -194,6 +194,28 @@ export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record(), + "copilot", + ); + } + return patch; } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts new file mode 100644 index 000000000..0354cc10a --- /dev/null +++ b/apps/web/src/modelSelection.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import type { ServerProvider } from "@t3tools/contracts"; +import { getCopilotBuiltInModelCapabilities } from "@t3tools/shared/copilot"; + +import { getAppModelOptions, resolveAppModelSelectionState } from "./modelSelection"; + +const providers: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-03-26T00:00:00.000Z", + models: [{ slug: "gpt-5.4-mini", name: "GPT-5.4 Mini", isCustom: false, capabilities: null }], + }, + { + provider: "copilot", + enabled: true, + installed: true, + version: "1.2.3", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-03-26T00:00:00.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: getCopilotBuiltInModelCapabilities("gpt-5.4"), + }, + ], + }, +]; + +describe("modelSelection", () => { + it("keeps Copilot text generation selections provider-specific", () => { + const selection = resolveAppModelSelectionState( + { + ...DEFAULT_UNIFIED_SETTINGS, + textGenerationModelSelection: { + provider: "copilot", + model: "gpt-5.4", + options: { + reasoningEffort: "medium", + }, + }, + }, + providers, + ); + + expect(selection).toEqual({ + provider: "copilot", + model: "gpt-5.4", + options: { + reasoningEffort: "medium", + }, + }); + }); + + it("includes the current Copilot selection even when it is only custom", () => { + const options = getAppModelOptions( + { + ...DEFAULT_UNIFIED_SETTINGS, + providers: { + ...DEFAULT_UNIFIED_SETTINGS.providers, + copilot: { + ...DEFAULT_UNIFIED_SETTINGS.providers.copilot, + customModels: ["gpt-5.4-preview"], + }, + }, + }, + providers, + "copilot", + "custom-preview", + ); + + expect(options).toEqual( + expect.arrayContaining([ + { slug: "gpt-5.4-preview", name: "gpt-5.4-preview", isCustom: true }, + { slug: "custom-preview", name: "custom-preview", isCustom: true }, + ]), + ); + }); +}); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884ad..480d1ae8b 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,7 +4,11 @@ import { type ProviderKind, type ServerProvider, } from "@t3tools/contracts"; -import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; +import { + buildModelSelection, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { @@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record, +): (typeof TEXT_GENERATION_PROVIDERS)[number] { + const requested = settings.textGenerationModelSelection.provider; + const requestedSnapshot = providers.find((provider) => provider.provider === requested); + if (requestedSnapshot?.enabled !== false) { + return requested; + } + + const fallback = TEXT_GENERATION_PROVIDERS.find((provider) => { + const snapshot = providers.find((candidate) => candidate.provider === provider); + return snapshot?.enabled !== false; + }); + return fallback ?? requested; +} + export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, @@ -176,7 +212,7 @@ export function resolveAppModelSelectionState( provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = resolveSelectableProvider(providers, selection.provider); + const provider = resolveTextGenerationProvider(settings, providers); // When the provider changed due to fallback (e.g. selected provider was disabled), // don't carry over the old provider's model — use the fallback provider's default. @@ -192,9 +228,5 @@ export function resolveAppModelSelectionState( }, }); - return { - provider, - model, - ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}), - }; + return buildModelSelection(provider, model, modelOptionsForDispatch); } diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index a925ed690..467059658 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,18 +1,15 @@ import { - DEFAULT_MODEL_BY_PROVIDER, type ClaudeModelOptions, + type CopilotModelOptions, type CodexModelOptions, + DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities, type ProviderKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { - getDefaultEffort, - hasEffortLevel, - normalizeModelSlug, - trimOrNull, -} from "@t3tools/shared/model"; +import { normalizeModelOptionsForProvider, normalizeModelSlug } from "@t3tools/shared/model"; +import { COPILOT_BUILT_IN_MODELS } from "@t3tools/shared/copilot"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -25,7 +22,11 @@ export function getProviderModels( providers: ReadonlyArray, provider: ProviderKind, ): ReadonlyArray { - return providers.find((candidate) => candidate.provider === provider)?.models ?? []; + const snapshotModels = providers.find((candidate) => candidate.provider === provider)?.models; + if (snapshotModels) { + return snapshotModels; + } + return provider === "copilot" ? COPILOT_BUILT_IN_MODELS : []; } export function getProviderSnapshot( @@ -74,43 +75,17 @@ export function getDefaultServerModel( ); } -export function normalizeCodexModelOptionsWithCapabilities( +export const normalizeCodexModelOptionsWithCapabilities = ( caps: ModelCapabilities, modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} +) => normalizeModelOptionsForProvider("codex", caps, modelOptions); -export function normalizeClaudeModelOptionsWithCapabilities( +export const normalizeClaudeModelOptionsWithCapabilities = ( caps: ModelCapabilities, modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} +) => normalizeModelOptionsForProvider("claudeAgent", caps, modelOptions); + +export const normalizeCopilotModelOptionsWithCapabilities = ( + caps: ModelCapabilities, + modelOptions: CopilotModelOptions | null | undefined, +) => normalizeModelOptionsForProvider("copilot", caps, modelOptions); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 3e92891a5..5757d7ab4 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -17,7 +17,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { buildModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useSettings, useUpdateSettings } from "../hooks/useSettings"; import { getCustomModelOptionsByProvider, @@ -82,9 +82,12 @@ type InstallProviderSettings = { title: string; binaryPlaceholder: string; binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; + secondaryField?: { + key: "homePath" | "configDir"; + label: string; + placeholder: string; + description?: ReactNode; + }; }; const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ @@ -93,9 +96,12 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ title: "Codex", binaryPlaceholder: "Codex binary path", binaryDescription: "Path to the Codex binary", - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", + secondaryField: { + key: "homePath", + label: "CODEX_HOME path", + placeholder: "CODEX_HOME", + description: "Optional custom Codex home and config directory.", + }, }, { provider: "claudeAgent", @@ -103,6 +109,18 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, + { + provider: "copilot", + title: "Copilot", + binaryPlaceholder: "Copilot binary path", + binaryDescription: "Optional path to the GitHub Copilot CLI binary.", + secondaryField: { + key: "configDir", + label: "Config directory", + placeholder: "~/.config/github-copilot", + description: "Optional custom GitHub Copilot config and state directory.", + }, + }, ]; const PROVIDER_STATUS_STYLES = { @@ -301,12 +319,20 @@ function SettingsRouteView() { DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0, ), + copilot: Boolean( + settings.providers.copilot.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.copilot.binaryPath || + settings.providers.copilot.configDir !== + DEFAULT_UNIFIED_SETTINGS.providers.copilot.configDir || + settings.providers.copilot.customModels.length > 0, + ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -335,7 +361,6 @@ function SettingsRouteView() { const modelListRefs = useRef>>({}); - const codexHomePath = settings.providers.codex.homePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; @@ -522,9 +547,7 @@ function SettingsRouteView() { title: providerSettings.title, binaryPlaceholder: providerSettings.binaryPlaceholder, binaryDescription: providerSettings.binaryDescription, - homePathKey: providerSettings.homePathKey, - homePlaceholder: providerSettings.homePlaceholder, - homeDescription: providerSettings.homeDescription, + secondaryField: providerSettings.secondaryField, binaryPathValue, isDirty, liveProvider, @@ -553,10 +576,12 @@ function SettingsRouteView() { setOpenProviderDetails({ codex: false, claudeAgent: false, + copilot: false, }); setCustomModelInputByProvider({ codex: "", claudeAgent: "", + copilot: "", }); setCustomModelErrorByProvider({}); } @@ -864,15 +889,16 @@ function SettingsRouteView() { triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { + const nextSelection = buildModelSelection( + textGenProvider, + textGenModel, + nextOptions, + ); updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), - }, + textGenerationModelSelection: nextSelection, }, serverProviders, ), @@ -1089,37 +1115,61 @@ function SettingsRouteView() { - {/* Home path (Codex only) */} - {providerCard.homePathKey ? ( + {providerCard.secondaryField ? (
@@ -1240,7 +1290,9 @@ function SettingsRouteView() { placeholder={ providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" - : "claude-sonnet-5-0" + : providerCard.provider === "claudeAgent" + ? "claude-sonnet-5-0" + : "gpt-5.4-preview" } spellCheck={false} /> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72..8422144ef 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1129,12 +1129,14 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { + it("advertises Copilot and Claude while keeping Cursor as a placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); + const copilot = PROVIDER_OPTIONS.find((option) => option.value === "copilot"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "copilot", label: "Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ @@ -1142,6 +1144,11 @@ describe("PROVIDER_OPTIONS", () => { label: "Claude", available: true, }); + expect(copilot).toEqual({ + value: "copilot", + label: "Copilot", + available: true, + }); expect(cursor).toEqual({ value: "cursor", label: "Cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d631..da87a19af 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "copilot", label: "Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4590b2886..b4a26a3cb 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -193,7 +193,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "copilot") { return providerName; } return "codex"; diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf2..ec1809ebf 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,15 +1,40 @@ import { ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vitest"; - -import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +let selectThreadTerminalState: typeof import("./terminalStateStore").selectThreadTerminalState; +let useTerminalStateStore: typeof import("./terminalStateStore").useTerminalStateStore; + +function createLocalStorageMock(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(key) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, value); + }, + }; +} + describe("terminalStateStore actions", () => { - beforeEach(() => { - if (typeof localStorage !== "undefined") { - localStorage.clear(); - } + beforeEach(async () => { + vi.resetModules(); + vi.stubGlobal("localStorage", createLocalStorageMock()); + ({ selectThreadTerminalState, useTerminalStateStore } = await import("./terminalStateStore")); useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); diff --git a/bun.lock b/bun.lock index 857d3a83c..05024f94d 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.10", + "@github/copilot-sdk": "0.2.0", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -361,6 +363,22 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], + "@github/copilot": ["@github/copilot@1.0.10", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.10", "@github/copilot-darwin-x64": "1.0.10", "@github/copilot-linux-arm64": "1.0.10", "@github/copilot-linux-x64": "1.0.10", "@github/copilot-win32-arm64": "1.0.10", "@github/copilot-win32-x64": "1.0.10" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-RpHYMXYpyAgQLYQ3MB8ubV8zMn/zDatwaNmdxcC8ws7jqM+Ojy7Dz4KFKzyT0rCrWoUCAEBXsXoPbP0LY0FgLw=="], + + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.10", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-MNlzwkTQ9iUgHQ+2Z25D0KgYZDEl4riEa1Z4/UCNpHXmmBiIY8xVRbXZTNMB69cnagjQ5Z8D2QM2BjI0kqeFPg=="], + + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.10", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-zAQBCbEue/n4xHBzE9T03iuupVXvLtu24MDMeXXtIC0d4O+/WV6j1zVJrp9Snwr0MBWYH+wUrV74peDDdd1VOQ=="], + + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.10", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-7mJ3uLe7ITyRi2feM1rMLQ5d0bmUGTUwV1ZxKZwSzWCYmuMn05pg4fhIUdxZZZMkLbOl3kG/1J7BxMCTdS2w7A=="], + + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.10", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-66NPaxroRScNCs6TZGX3h1RSKtzew0tcHBkj4J1AHkgYLjNHMdjjBwokGtKeMxzYOCAMBbmJkUDdNGkqsKIKUA=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@0.2.0", "", { "dependencies": { "@github/copilot": "^1.0.10", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-fCEpD9W9xqcaCAJmatyNQ1PkET9P9liK2P4Vk0raDFoMXcvpIdqewa5JQeKtWCBUsN/HCz7ExkkFP8peQuo+DA=="], + + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.10", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-WC5M+M75sxLn4lvZ1wPA1Lrs/vXFisPXJPCKbKOMKqzwMLX/IbuybTV4dZDIyGEN591YmOdRIylUF0tVwO8Zmw=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.10", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-tUfIwyamd0zpm9DVTtbjIWF6j3zrA5A5IkkiuRgsy0HRJPQpeAV7ZYaHEZteHrynaULpl1Gn/Dq0IB4hYc4QtQ=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], @@ -1823,7 +1841,7 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1995,6 +2013,8 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 68ca11047..3ecde8d91 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -21,9 +21,15 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const CopilotModelOptions = Schema.Struct({ + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), +}); +export type CopilotModelOptions = typeof CopilotModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + copilot: Schema.optional(CopilotModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -47,6 +53,7 @@ export type ModelSlug = string & {}; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + copilot: "gpt-5.4", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -55,6 +62,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", + copilot: "gpt-5.4-mini", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -79,6 +87,20 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + copilot: "Copilot", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0b40bb6fd..c604b1c58 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { ClaudeModelOptions, CopilotModelOptions, CodexModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "copilot"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -58,7 +58,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const CopilotModelSelection = Schema.Struct({ + provider: Schema.Literal("copilot"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(CopilotModelOptions), +}); +export type CopilotModelSelection = typeof CopilotModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + CopilotModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f..166b052fb 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "copilot.sdk.session-event", + "copilot.sdk.synthetic", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8ce01f630..b080d512f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -4,6 +4,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; import { ClaudeModelOptions, + CopilotModelOptions, CodexModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, } from "./model"; @@ -70,6 +71,14 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const CopilotSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + configDir: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type CopilotSettings = typeof CopilotSettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( @@ -86,6 +95,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + copilot: CopilotSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -113,6 +123,10 @@ const ClaudeModelOptionsPatch = Schema.Struct({ fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), }); +const CopilotModelOptionsPatch = Schema.Struct({ + reasoningEffort: Schema.optionalKey(CopilotModelOptions.fields.reasoningEffort), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -124,6 +138,11 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("copilot")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CopilotModelOptionsPatch), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -139,6 +158,13 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CopilotSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + configDir: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -147,6 +173,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + copilot: Schema.optionalKey(CopilotSettingsPatch), }), ), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index d34d1ce45..f723f4065 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./copilot": { + "types": "./src/copilot.ts", + "import": "./src/copilot.ts" + }, "./logging": { "types": "./src/logging.ts", "import": "./src/logging.ts" diff --git a/packages/shared/src/copilot.ts b/packages/shared/src/copilot.ts new file mode 100644 index 000000000..3af45d17c --- /dev/null +++ b/packages/shared/src/copilot.ts @@ -0,0 +1,78 @@ +import type { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; + +const COPILOT_REASONING_CAPABILITIES = { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +} satisfies ModelCapabilities; + +const COPILOT_BASIC_CAPABILITIES = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +} satisfies ModelCapabilities; + +export const COPILOT_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: COPILOT_REASONING_CAPABILITIES, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + isCustom: false, + capabilities: COPILOT_REASONING_CAPABILITIES, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: COPILOT_REASONING_CAPABILITIES, + }, + { + slug: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: COPILOT_BASIC_CAPABILITIES, + }, + { + slug: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: COPILOT_BASIC_CAPABILITIES, + }, + { + slug: "claude-opus-4.6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: COPILOT_BASIC_CAPABILITIES, + }, + { + slug: "claude-opus-4.6-fast", + name: "Claude Opus 4.6 (Fast Mode)", + isCustom: false, + capabilities: COPILOT_BASIC_CAPABILITIES, + }, + { + slug: "gemini-3.0", + name: "Gemini 3.0 Pro", + isCustom: false, + capabilities: COPILOT_BASIC_CAPABILITIES, + }, +]; + +export function getCopilotBuiltInModelCapabilities( + model: string | null | undefined, +): ModelCapabilities | null { + const slug = model?.trim(); + return COPILOT_BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? null; +} diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 31f0d0a11..854a50997 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,14 +3,17 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities, + type ProviderModelOptions, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + buildModelSelection, getDefaultEffort, hasEffortLevel, isClaudeUltrathinkPrompt, normalizeModelSlug, + normalizeModelOptionsForProvider, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -109,3 +112,44 @@ describe("misc helpers", () => { expect(trimOrNull(" ")).toBeNull(); }); }); + +describe("buildModelSelection", () => { + it("builds provider-specific selections without widening the union", () => { + expect(buildModelSelection("codex", "gpt-5.4", { fastMode: true })).toEqual({ + provider: "codex", + model: "gpt-5.4", + options: { fastMode: true }, + }); + expect(buildModelSelection("claudeAgent", "claude-sonnet-4-6", { effort: "medium" })).toEqual({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "medium" }, + }); + expect(buildModelSelection("copilot", "gpt-5.4-mini", { reasoningEffort: "low" })).toEqual({ + provider: "copilot", + model: "gpt-5.4-mini", + options: { reasoningEffort: "low" }, + }); + }); +}); + +describe("normalizeModelOptionsForProvider", () => { + it("normalizes provider model options through the shared helper", () => { + expect( + normalizeModelOptionsForProvider("codex", codexCaps, { + reasoningEffort: "high", + fastMode: true, + }), + ).toEqual({ fastMode: true }); + expect( + normalizeModelOptionsForProvider("claudeAgent", claudeCaps, { + effort: "medium", + }), + ).toEqual({ effort: "medium" }); + expect( + normalizeModelOptionsForProvider("copilot", codexCaps, { + reasoningEffort: "xhigh", + }), + ).toEqual({ reasoningEffort: "xhigh" }); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e633aeb29..57918e7f2 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -2,9 +2,15 @@ import { DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, + type ClaudeModelOptions, + type CopilotModelOptions, + type CodexModelOptions, + type CodexReasoningEffort, type ModelCapabilities, + type ModelSelection, type ModelSlug, type ProviderKind, + type ProviderModelOptions, } from "@t3tools/contracts"; export interface SelectableModelOption { @@ -110,6 +116,116 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +export function normalizeCodexModelOptions( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptions( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeCopilotModelOptions( + caps: ModelCapabilities, + modelOptions: CopilotModelOptions | null | undefined, +): CopilotModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort | null; + const resolvedReasoningEffort = trimOrNull(modelOptions?.reasoningEffort); + const reasoningEffort = + resolvedReasoningEffort && + hasEffortLevel(caps, resolvedReasoningEffort) && + resolvedReasoningEffort !== defaultReasoningEffort + ? resolvedReasoningEffort + : undefined; + return reasoningEffort ? { reasoningEffort } : undefined; +} + +export function normalizeModelOptionsForProvider

( + provider: P, + caps: ModelCapabilities, + modelOptions: ProviderModelOptions[P] | null | undefined, +): ProviderModelOptions[P] | undefined { + switch (provider) { + case "codex": + return normalizeCodexModelOptions(caps, modelOptions as CodexModelOptions | undefined) as + | ProviderModelOptions[P] + | undefined; + case "claudeAgent": + return normalizeClaudeModelOptions(caps, modelOptions as ClaudeModelOptions | undefined) as + | ProviderModelOptions[P] + | undefined; + case "copilot": + return normalizeCopilotModelOptions(caps, modelOptions as CopilotModelOptions | undefined) as + | ProviderModelOptions[P] + | undefined; + } +} + +export function buildModelSelection

( + provider: P, + model: ModelSlug, + options?: ProviderModelOptions[P], +): Extract { + switch (provider) { + case "codex": { + const codexOptions = options as ProviderModelOptions["codex"] | undefined; + return ( + codexOptions + ? { provider: "codex", model, options: codexOptions } + : { provider: "codex", model } + ) as Extract; + } + case "claudeAgent": { + const claudeOptions = options as ProviderModelOptions["claudeAgent"] | undefined; + return ( + claudeOptions + ? { provider: "claudeAgent", model, options: claudeOptions } + : { provider: "claudeAgent", model } + ) as Extract; + } + case "copilot": { + const copilotOptions = options as ProviderModelOptions["copilot"] | undefined; + return ( + copilotOptions + ? { provider: "copilot", model, options: copilotOptions } + : { provider: "copilot", model } + ) as Extract; + } + } +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined,