diff --git a/apps/server/package.json b/apps/server/package.json index edcb004ded..0654601b86 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,6 +25,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/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f1cf6afc91..bfbca44568 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -100,7 +100,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - (input.provider === "codex" || input.provider === "claudeAgent") + (input.provider === "codex" || + input.provider === "claudeAgent" || + input.provider === "copilot") ? input.provider : "codex"; const resumeCursor = @@ -217,10 +219,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(layer); + const testRuntime = (runtime = ManagedRuntime.make(layer)); - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const engine = await testRuntime.runPromise(Effect.service(OrchestrationEngineService)); + const reactor = await testRuntime.runPromise(Effect.service(ProviderCommandReactor)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -288,8 +290,8 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.startSession.mock.calls.length > 0); + await waitFor(() => harness.sendTurn.mock.calls.length > 0); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", @@ -332,8 +334,8 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.startSession.mock.calls.length > 0); + await waitFor(() => harness.sendTurn.mock.calls.length > 0); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ model: "gpt-5.3-codex", modelOptions: { @@ -493,7 +495,7 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a first turn when requested provider conflicts with the thread model", async () => { + it("binds a pristine thread to the explicitly requested provider on first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -505,44 +507,34 @@ describe("ProviderCommandReactor", () => { message: { messageId: asMessageId("user-message-provider-first"), role: "user", - text: "hello claude", + text: "hello copilot", attachments: [], }, - provider: "claudeAgent", + provider: "copilot", + model: "claude-sonnet-4.6", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.startSession).toHaveBeenCalledWith( + ThreadId.makeUnsafe("thread-1"), + expect.objectContaining({ + provider: "copilot", + model: "claude-sonnet-4.6", + }), + ); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session).toBeNull(); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - summary: "Provider turn start failed", - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(thread?.session?.providerName).toBe("copilot"); }); - it("rejects a turn when the requested model belongs to a different provider", async () => { + it("infers the provider from the selected model on a pristine thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -564,6 +556,60 @@ describe("ProviderCommandReactor", () => { }), ); + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession).toHaveBeenCalledWith( + ThreadId.makeUnsafe("thread-1"), + expect.objectContaining({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }), + ); + }); + + it("rejects a provider-scoped model change after the thread is already bound", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-established-model-provider-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-established-model-provider-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-established-model-provider-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-established-model-provider-2"), + role: "user", + text: "second", + attachments: [], + }, + model: "claude-sonnet-4-6", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find( @@ -575,8 +621,8 @@ describe("ProviderCommandReactor", () => { ); }); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..91a8b4795d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -26,7 +26,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { inferProviderForModel } from "@t3tools/shared/model"; +import { getModelOptions, inferProviderForModel, normalizeModelSlug } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -139,6 +139,13 @@ function buildGeneratedWorktreeBranchName(raw: string): string { return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; } +function isBuiltInModelForProvider(provider: ProviderKind, model: string | undefined): boolean { + const normalized = normalizeModelSlug(model, provider); + return ( + normalized !== null && getModelOptions(provider).some((option) => option.slug === normalized) + ); +} + const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; @@ -233,8 +240,23 @@ const make = Effect.gen(function* () { ) ? thread.session.providerName : undefined; - const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); - if (options?.provider !== undefined && options.provider !== threadProvider) { + const defaultThreadProvider = inferProviderForModel(thread.model); + const isThreadProviderLocked = currentProvider !== undefined || thread.latestTurn !== null; + const requestedProvider = + options?.provider ?? + (options?.model !== undefined + ? inferProviderForModel(options.model, defaultThreadProvider) + : undefined); + const threadProvider: ProviderKind = + currentProvider ?? + (isThreadProviderLocked + ? defaultThreadProvider + : (requestedProvider ?? defaultThreadProvider)); + if ( + isThreadProviderLocked && + options?.provider !== undefined && + options.provider !== threadProvider + ) { return yield* new ProviderAdapterRequestError({ provider: threadProvider, method: "thread.turn.start", @@ -243,7 +265,8 @@ const make = Effect.gen(function* () { } if ( options?.model !== undefined && - inferProviderForModel(options.model, threadProvider) !== threadProvider + inferProviderForModel(options.model, threadProvider) !== threadProvider && + !isBuiltInModelForProvider(threadProvider, options.model) ) { return yield* new ProviderAdapterRequestError({ provider: threadProvider, @@ -251,7 +274,8 @@ const make = Effect.gen(function* () { detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, }); } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; + const preferredProvider: ProviderKind = + currentProvider ?? requestedProvider ?? defaultThreadProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, 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 0000000000..1f38648f08 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,450 @@ +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 { Effect, Fiber, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { 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 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 = { + 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; +} + +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(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(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(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"), + model: "gpt-5.4", + modelOptions: { + copilot: { + reasoningEffort: "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 reasoning effort without an explicit model", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([]); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-no-model"), + modelOptions: { + copilot: { + reasoningEffort: "high", + }, + }, + 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 reasoning effort requires an explicit supported model selection.", + }), + ); + 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.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"), + model: "gpt-5.4", + modelOptions: { + copilot: { + reasoningEffort: "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); + }), + ); + + it.effect("reconfigures the session when reasoning effort changes", () => + Effect.gen(function* () { + 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"), + model: "gpt-5.4", + modelOptions: { + copilot: { + reasoningEffort: "high", + }, + }, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Switch effort", + modelOptions: { + copilot: { + reasoningEffort: "low", + }, + }, + attachments: [], + }); + + assert.equal(reasoningSession.disconnectImpl.mock.calls.length, 1); + assert.equal(reasoningSession.destroyImpl.mock.calls.length, 0); + assert.equal( + reasoningClient.resumeSessionImpl.mock.calls[0]?.[0], + "copilot-session-reasoning", + ); + const resumedConfig = reasoningClient.resumeSessionImpl.mock.calls[0]?.[1] as Record< + string, + unknown + >; + assert.equal(resumedConfig.model, "gpt-5.4"); + assert.equal(resumedConfig.reasoningEffort, "low"); + assert.equal(resumedConfig.streaming, true); + assert.equal(typeof resumedConfig.onPermissionRequest, "function"); + assert.equal(typeof resumedConfig.onUserInputRequest, "function"); + assert.equal(reasoningSession.sendImpl.mock.calls.length, 1); + }), + ); +}); + +afterAll(() => { + void modeSession.disconnect(); + void modeClient.stop(); + void planSession.disconnect(); + void planClient.stop(); + void reasoningSession.disconnect(); + void reasoningClient.stop(); +}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 0000000000..0b9306a2b9 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,1684 @@ +import { randomUUID } from "node:crypto"; + +import { + type CodexReasoningEffort, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + CopilotClient, + type CopilotClientOptions, + type ModelInfo, + type PermissionRequest, + type PermissionRequestResult, + type SessionEvent, +} from "@github/copilot-sdk"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.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 { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.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; + 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; + toolTitlesByCallId: Map; + pendingApprovalResolvers: Map; + pendingUserInputResolvers: Map; + unsubscribe: () => void; +} + +interface CopilotSessionHandle { + readonly sessionId: string; + readonly rpc: { + 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 trimToUndefined(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : 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 mapSupportedModelsById(models: ReadonlyArray) { + return new Map(models.map((model) => [model.id, model])); +} + +function getCopilotReasoningEffort(modelOptions: unknown) { + const record = asRecord(modelOptions); + const copilot = asRecord(record?.copilot); + const reasoningEffort = normalizeString(copilot?.reasoningEffort); + return reasoningEffort === "low" || + reasoningEffort === "medium" || + reasoningEffort === "high" || + reasoningEffort === "xhigh" + ? reasoningEffort + : undefined; +} + +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 "mcp": + case "url": + case "custom-tool": + return "dynamic_tool_call" as const; + default: + return "unknown" as const; + } +} + +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" : "dynamic_tool_call"; +} + +function toolDetailFromEvent(data: { + readonly toolName?: string; + readonly mcpToolName?: string; + readonly mcpServerName?: string; +}) { + return trimToUndefined( + [data.mcpServerName, data.mcpToolName ?? data.toolName].filter(Boolean).join(" / "), + ); +} + +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 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, + 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, + toolTitlesByCallId: new Map(), + pendingApprovalResolvers: input.pendingApprovalResolvers, + pendingUserInputResolvers: input.pendingUserInputResolvers, + unsubscribe: () => undefined, + }; +} + +const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + 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": + return [ + { + ...base(), + type: "thread.token-usage.updated", + payload: { + usage: event.data, + }, + }, + ]; + 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 completionRefs = completionTurnRefs(record); + const completionBase = + completionRefs.turnId || completionRefs.providerTurnId ? base(completionRefs) : base(); + return [ + { + ...completionBase, + type: "thread.token-usage.updated", + payload: { + usage: event.data, + }, + }, + ]; + } + 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": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.started", + payload: { + itemType: itemTypeFromToolEvent(event), + status: "inProgress", + title: event.data.toolName ?? "Tool call", + ...(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": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.completed", + payload: { + itemType: event.data.result?.contents?.some( + (content) => content.type === "terminal", + ) + ? "command_execution" + : "dynamic_tool_call", + status: event.data.success ? "completed" : "failed", + title: record.toolTitlesByCallId.get(event.data.toolCallId) ?? "Tool call", + ...(trimToUndefined(event.data.result?.content) + ? { detail: event.data.result?.content } + : {}), + data: event.data, + }, + }, + ...(trimToUndefined(event.data.result?.content) + ? [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.summary" as const, + payload: { + summary: event.data.result?.content ?? "", + 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; + } + + yield* Effect.tryPromise({ + try: () => input.client.start(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot client."), + cause, + }), + }); + + const supportedModels = mapSupportedModelsById( + yield* Effect.tryPromise({ + try: () => input.client.listModels(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to load GitHub Copilot model metadata."), + cause, + }), + }), + ); + const selectedModel = input.model ? supportedModels.get(input.model) : undefined; + + if (input.model && !selectedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.model", + issue: `GitHub Copilot model '${input.model}' is not available in the current Copilot runtime.`, + }); + } + + if (!input.reasoningEffort) { + return; + } + + if (!selectedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: + "GitHub Copilot reasoning effort requires an explicit supported model selection.", + }); + } + + const supportedReasoningEfforts = selectedModel.supportedReasoningEfforts ?? []; + if (supportedReasoningEfforts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${selectedModel.id}' does not support reasoning effort configuration.`, + }); + } + + if (!supportedReasoningEfforts.includes(input.reasoningEffort)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${selectedModel.id}' does not support reasoning effort '${input.reasoningEffort}'.`, + }); + } + }); + + const reconfigureSession = ( + record: ActiveCopilotSession, + input: { + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }, + ) => + Effect.tryPromise({ + try: async () => { + const sessionId = record.session.sessionId; + const previousSession = record.session; + const previousUnsubscribe = record.unsubscribe; + previousUnsubscribe(); + await closeCopilotSession(previousSession); + + const handlers = createInteractionHandlers( + record.threadId, + () => record.currentTurnId, + () => record.runtimeMode, + record.pendingApprovalResolvers, + record.pendingUserInputResolvers, + ); + const nextSession = await record.client.resumeSession(sessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), + ...(record.cwd ? { workingDirectory: record.cwd } : {}), + ...(record.configDir ? { configDir: record.configDir } : {}), + streaming: true, + }); + + record.session = nextSession; + record.model = input.model; + record.reasoningEffort = input.reasoningEffort; + record.interactionMode = undefined; + record.updatedAt = new Date().toISOString(); + record.unsubscribe = nextSession.on((event) => { + handleSessionEvent(record, event); + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.reconfigure", + 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" && trimToUndefined(event.data.toolName)) { + record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); + } + + 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.toolTitlesByCallId.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 record.client.stop(); + } 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}'.`, + }); + } + + 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 cliPath = + normalizeCopilotCliPathOverride(input.providerOptions?.copilot?.cliPath) ?? + resolveBundledCopilotCliPath(); + const configDir = trimToUndefined(input.providerOptions?.copilot?.configDir); + const resumeSessionId = extractResumeSessionId(input.resumeCursor); + const clientOptions: CopilotClientOptions = { + ...(cliPath ? { cliPath } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + logLevel: "error", + }; + const client = options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions); + const pendingApprovalResolvers = new Map(); + const pendingUserInputResolvers = new Map(); + const reasoningEffort = getCopilotReasoningEffort(input.modelOptions); + let sessionRecord: ActiveCopilotSession | undefined; + const handlers = createInteractionHandlers( + input.threadId, + () => sessionRecord?.currentTurnId, + () => sessionRecord?.runtimeMode ?? input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + ); + + yield* validateSessionConfiguration({ + client, + threadId: input.threadId, + model: input.model, + reasoningEffort, + }); + + const session = yield* Effect.tryPromise({ + try: async () => { + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + } + const sessionConfig: Parameters[0] & { + sessionId?: string; + } = { + ...handlers, + sessionId: makeCopilotSessionId(input.threadId), + ...(input.model ? { model: input.model } : {}), + ...(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, + }), + }); + + const record = createSessionRecord({ + threadId: input.threadId, + client, + session, + runtimeMode: input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + cwd: input.cwd, + configDir, + model: input.model, + 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 } : {}), + ...(input.model ? { model: input.model } : {}), + ...(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 } : {}), + ...(input.model ? { model: input.model } : {}), + 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); + const explicitReasoningEffort = getCopilotReasoningEffort(input.modelOptions); + const nextModel = input.model ?? record.model; + const nextReasoningEffort = + explicitReasoningEffort !== undefined + ? explicitReasoningEffort + : input.model && input.model !== record.model + ? undefined + : record.reasoningEffort; + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + 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, + }); + }); + + yield* validateSessionConfiguration({ + client: record.client, + threadId: input.threadId, + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + + if (nextModel !== record.model || nextReasoningEffort !== record.reasoningEffort) { + 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/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..1d53b36cd6 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, type 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 23ef8d1b9b..588c7d2ed0 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/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index e24f07bcfa..0eaf2d32c7 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -8,6 +8,7 @@ import { checkClaudeProviderStatus, checkCodexProviderStatus, hasCustomModelProvider, + mapCopilotModel, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, readCodexConfigModelProvider, @@ -95,6 +96,28 @@ function withTempCodexHome(configContent?: string) { } it.layer(NodeServices.layer)("ProviderHealth", (it) => { + describe("mapCopilotModel", () => { + it("maps Copilot runtime model metadata for reasoning-aware pickers", () => { + assert.deepStrictEqual( + mapCopilotModel({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + billing: { multiplier: 1.25 }, + } as never), + { + id: "gpt-5.4", + name: "GPT-5.4", + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + billingMultiplier: 1.25, + }, + ); + }); + }); + // ── checkCodexProviderStatus tests ──────────────────────────────── // // These tests control CODEX_HOME to ensure the custom-provider detection diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..b29b52c21a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -11,10 +11,12 @@ import * as OS from "node:os"; import type { ServerProviderAuthStatus, + ServerProviderModel, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; +import { Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -23,10 +25,21 @@ import { parseCodexCliVersion, } from "../codexCliVersion"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; +import { resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; +const COPILOT_PROVIDER = "copilot" as const; + +interface CopilotHealthProbeError { + readonly _tag: "CopilotHealthProbeError"; + readonly cause: unknown; +} + +export function getCopilotHealthCheckTimeoutMs(platform: string = process.platform): number { + return platform === "win32" ? 10_000 : DEFAULT_TIMEOUT_MS; +} // ── Pure helpers ──────────────────────────────────────────────────── @@ -36,6 +49,23 @@ export interface CommandResult { readonly code: number; } +export function mapCopilotModel(model: ModelInfo): ServerProviderModel { + return { + id: model.id, + name: model.name, + supportsReasoningEffort: (model.supportedReasoningEfforts?.length ?? 0) > 0, + ...(model.supportedReasoningEfforts && model.supportedReasoningEfforts.length > 0 + ? { supportedReasoningEfforts: [...model.supportedReasoningEfforts] } + : {}), + ...(model.defaultReasoningEffort + ? { defaultReasoningEffort: model.defaultReasoningEffort } + : {}), + ...(typeof model.billing?.multiplier === "number" + ? { billingMultiplier: model.billing.multiplier } + : {}), + } satisfies ServerProviderModel; +} + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -587,14 +617,103 @@ export const checkClaudeProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCopilotProviderStatus: Effect.Effect = Effect.gen( + function* () { + const checkedAt = new Date().toISOString(); + const probe = yield* Effect.tryPromise({ + try: async () => { + const cliPath = resolveBundledCopilotCliPath(); + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }); + + try { + await client.start(); + const [status, authStatus] = await Promise.all([ + client.getStatus(), + client.getAuthStatus().catch(() => undefined), + ]); + const models = + authStatus?.isAuthenticated === true + ? await client.listModels().catch(() => undefined) + : undefined; + return { status, authStatus, models }; + } finally { + await client.stop().catch(() => []); + } + }, + catch: (cause) => + ({ + _tag: "CopilotHealthProbeError", + cause, + }) satisfies CopilotHealthProbeError, + }).pipe(Effect.timeoutOption(getCopilotHealthCheckTimeoutMs()), Effect.result); + + if (Result.isFailure(probe)) { + const error = probe.failure.cause; + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Failed to start GitHub Copilot CLI health check: ${error.message}.` + : "Failed to start GitHub Copilot CLI health check.", + }; + } + + if (Option.isNone(probe.success)) { + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "GitHub Copilot CLI health check timed out while starting the SDK client.", + }; + } + + const authStatus: ServerProviderAuthStatus = + probe.success.value.authStatus?.isAuthenticated === true + ? "authenticated" + : probe.success.value.authStatus?.isAuthenticated === false + ? "unauthenticated" + : "unknown"; + const status: ServerProviderStatusState = + authStatus === "unauthenticated" ? "error" : authStatus === "unknown" ? "warning" : "ready"; + + return { + provider: COPILOT_PROVIDER, + status, + available: true, + authStatus, + checkedAt, + ...(probe.success.value.models && probe.success.value.models.length > 0 + ? { models: probe.success.value.models.map(mapCopilotModel) } + : {}), + ...(probe.success.value.authStatus?.statusMessage + ? { message: probe.success.value.authStatus.statusMessage } + : probe.success.value.status?.version + ? { message: `GitHub Copilot CLI ${probe.success.value.status.version}` } + : {}), + } satisfies ServerProviderStatus; + }, +); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); + const statusesFiber = yield* Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus, checkCopilotProviderStatus], + { + concurrency: "unbounded", + }, + ).pipe(Effect.forkScoped); return { getStatuses: Fiber.join(statusesFiber), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d696..a24e933e08 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 0000000000..7aa2297528 --- /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 0000000000..dce3dc77b4 --- /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/copilotTurnTracking.test.ts b/apps/server/src/provider/Layers/copilotTurnTracking.test.ts new file mode 100644 index 0000000000..004f9a0112 --- /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 0000000000..ff2622858a --- /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 0000000000..4c8b995891 --- /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/serverLayers.ts b/apps/server/src/serverLayers.ts index 4486920dae..5a93456969 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,6 +20,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"; @@ -62,9 +63,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/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f12..8fa9dca80e 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' +const PACKAGE_VERSION = '2.12.11' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..6cb9ad2247 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -75,31 +75,45 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: ["galapagos-alpha"], claudeAgent: [] }, + { codex: ["galapagos-alpha"], claudeAgent: [], copilot: [] }, "galapagos-alpha", ), ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [], copilot: [] }, "")).toBe( + "gpt-5.4", + ); }); it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( - "gpt-5.3-codex", - ); + expect( + resolveAppModelSelection( + "codex", + { codex: [], claudeAgent: [], copilot: [] }, + "GPT-5.3 Codex", + ), + ).toBe("gpt-5.3-codex"); }); it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( - "claude-sonnet-4-6", - ); + expect( + resolveAppModelSelection( + "claudeAgent", + { codex: [], claudeAgent: [], copilot: [] }, + "sonnet", + ), + ).toBe("claude-sonnet-4-6"); }); it("resolves transient selected custom models included in app model options", () => { expect( - resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), + resolveAppModelSelection( + "codex", + { codex: [], claudeAgent: [], copilot: [] }, + "custom/selected-model", + ), ).toBe("custom/selected-model"); }); }); @@ -122,30 +136,37 @@ describe("provider-indexed custom model settings", () => { const settings = { customCodexModels: ["custom/codex-model"], customClaudeModels: ["claude/custom-opus"], + customCopilotModels: ["copilot/custom-model"], } as const; it("exports one provider config per provider", () => { expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ "codex", "claudeAgent", + "copilot", ]); }); it("reads custom models for each provider", () => { expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); + expect(getCustomModelsForProvider(settings, "copilot")).toEqual(["copilot/custom-model"]); }); it("reads default custom models for each provider", () => { const defaults = { customCodexModels: ["default/codex-model"], customClaudeModels: ["claude/default-opus"], + customCopilotModels: ["default/copilot-model"], } as const; expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ "claude/default-opus", ]); + expect(getDefaultCustomModelsForProvider(defaults, "copilot")).toEqual([ + "default/copilot-model", + ]); }); it("patches custom models for codex", () => { @@ -160,10 +181,17 @@ describe("provider-indexed custom model settings", () => { }); }); + it("patches custom models for copilot", () => { + expect(patchCustomModels("copilot", ["copilot/custom-model"])).toEqual({ + customCopilotModels: ["copilot/custom-model"], + }); + }); + it("builds a complete provider-indexed custom model record", () => { expect(getCustomModelsByProvider(settings)).toEqual({ codex: ["custom/codex-model"], claudeAgent: ["claude/custom-opus"], + copilot: ["copilot/custom-model"], }); }); @@ -176,12 +204,16 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), ).toBe(true); + expect( + modelOptionsByProvider.copilot.some((option) => option.slug === "copilot/custom-model"), + ).toBe(true); }); it("normalizes and deduplicates custom model options per provider", () => { const modelOptionsByProvider = getCustomModelOptionsByProvider({ customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], + customCopilotModels: [" sonnet ", "copilot/custom-model", "copilot/custom-model"], }); expect( @@ -194,6 +226,12 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), ).toBe(true); + expect( + modelOptionsByProvider.copilot.filter((option) => option.slug === "copilot/custom-model"), + ).toHaveLength(1); + expect( + modelOptionsByProvider.copilot.some((option) => option.slug === "claude-sonnet-4.6"), + ).toBe(true); }); }); @@ -217,6 +255,7 @@ describe("AppSettingsSchema", () => { timestampFormat: DEFAULT_TIMESTAMP_FORMAT, customCodexModels: [], customClaudeModels: [], + customCopilotModels: [], }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..73ffe1cac0 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -17,7 +17,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels" | "customCopilotModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -31,6 +31,7 @@ export type ProviderCustomModelConfig = { const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), + copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), }; const withDefaults = @@ -49,12 +50,15 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + copilotCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customCopilotModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -84,6 +88,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record getCustomModelsByProvider(settings), [settings]); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; @@ -1014,7 +1016,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1104,7 +1105,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -3132,12 +3132,14 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + runtimeModels: activeProviderStatus?.models, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, threadId, model: selectedModel, + runtimeModels: activeProviderStatus?.models, onPromptChange: setPromptFromTraits, }); const onEnvModeChange = useCallback( diff --git a/apps/web/src/components/chat/CopilotTraitsPicker.browser.tsx b/apps/web/src/components/chat/CopilotTraitsPicker.browser.tsx new file mode 100644 index 0000000000..91766ff0a5 --- /dev/null +++ b/apps/web/src/components/chat/CopilotTraitsPicker.browser.tsx @@ -0,0 +1,159 @@ +import "../../index.css"; + +import { ProjectId, ThreadId, type ServerProviderModel } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { CopilotTraitsPicker } from "./CopilotTraitsPicker"; +import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore"; + +async function mountPicker(props: { + model: string; + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; + runtimeModels?: ReadonlyArray; +}) { + const threadId = ThreadId.makeUnsafe(`thread-copilot-traits-${props.model}`); + const draftsByThreadId = {} as ReturnType< + typeof useComposerDraftStore.getState + >["draftsByThreadId"]; + draftsByThreadId[threadId] = { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: "copilot", + model: props.model, + modelOptions: props.reasoningEffort + ? { + copilot: { + reasoningEffort: props.reasoningEffort, + }, + } + : null, + runtimeMode: null, + interactionMode: null, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: { + [ProjectId.makeUnsafe("project-copilot-traits")]: threadId, + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { container: host }, + ); + + return { + threadId, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("CopilotTraitsPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("shows only the runtime-supported reasoning options for the selected model", async () => { + const mounted = await mountPicker({ + model: "gpt-5.4", + runtimeModels: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium"], + }, + ], + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Use model default"); + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).not.toContain("High"); + expect(text).not.toContain("Extra High"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to known static Copilot reasoning models when runtime metadata is unavailable", async () => { + const mounted = await mountPicker({ + model: "gpt-5.4", + }); + + try { + await vi.waitFor(() => { + expect(document.querySelector("button")).not.toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hides the picker when the selected model is not known to support reasoning", async () => { + const mounted = await mountPicker({ + model: "claude-sonnet-4.6", + }); + + try { + await vi.waitFor(() => { + expect(document.querySelector("button")).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("persists sticky Copilot reasoning when the selection changes", async () => { + const mounted = await mountPicker({ + model: "gpt-5.4", + runtimeModels: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high"], + }, + ], + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "High" }).click(); + + expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ + copilot: { + reasoningEffort: "high", + }, + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/CopilotTraitsPicker.tsx b/apps/web/src/components/chat/CopilotTraitsPicker.tsx new file mode 100644 index 0000000000..13e12d065e --- /dev/null +++ b/apps/web/src/components/chat/CopilotTraitsPicker.tsx @@ -0,0 +1,134 @@ +import type { + CodexReasoningEffort, + ProviderKind, + ServerProviderModel, + ThreadId, +} from "@t3tools/contracts"; +import { + normalizeCopilotModelOptions, + resolveCopilotReasoningCapability, + resolveReasoningEffortForProvider, +} from "@t3tools/shared/model"; +import { memo, useMemo, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { Button } from "../ui/button"; +import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; + +const PROVIDER = "copilot" as const satisfies ProviderKind; + +const COPILOT_REASONING_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; + +function getSelectedCopilotReasoning( + modelOptions: { reasoningEffort?: CodexReasoningEffort | undefined } | null | undefined, +) { + return resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort); +} + +function CopilotTraitsMenuContentImpl(props: { + threadId: ThreadId; + model: string; + runtimeModels: ReadonlyArray | undefined; +}) { + const draft = useComposerThreadDraft(props.threadId); + const modelOptions = draft.modelOptions?.[PROVIDER]; + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const capability = useMemo( + () => resolveCopilotReasoningCapability(props.model, props.runtimeModels), + [props.model, props.runtimeModels], + ); + const selectedEffort = getSelectedCopilotReasoning(modelOptions); + + if (!capability.supported) { + return null; + } + + return ( + +
Reasoning
+ { + if (!value) return; + + if (value === "default") { + setProviderModelOptions(props.threadId, PROVIDER, undefined, { persistSticky: true }); + return; + } + + const nextEffort = capability.options.find((option) => option === value); + if (!nextEffort) return; + setProviderModelOptions( + props.threadId, + PROVIDER, + normalizeCopilotModelOptions({ + ...modelOptions, + reasoningEffort: nextEffort, + }), + { persistSticky: true }, + ); + }} + > + Use model default + {capability.options.map((option) => ( + + {COPILOT_REASONING_LABELS[option]} + + ))} + +
+ ); +} + +export const CopilotTraitsMenuContent = memo(CopilotTraitsMenuContentImpl); + +export const CopilotTraitsPicker = memo(function CopilotTraitsPicker(props: { + threadId: ThreadId; + model: string; + runtimeModels: ReadonlyArray | undefined; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.copilot; + const capability = useMemo( + () => resolveCopilotReasoningCapability(props.model, props.runtimeModels), + [props.model, props.runtimeModels], + ); + const selectedEffort = getSelectedCopilotReasoning(modelOptions); + + if (!capability.supported) { + return null; + } + + const triggerLabel = selectedEffort ? COPILOT_REASONING_LABELS[selectedEffort] : "Reasoning"; + + return ( + setIsMenuOpen(open)}> + + } + > + + {triggerLabel} + + + + + + + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888e..719a07f315 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -5,6 +5,12 @@ export interface TimelineDurationMessage { completedAt?: string | undefined; } +export interface TimelineNoResponseAssistantMessage { + role: "user" | "assistant" | "system"; + text: string; + streaming: boolean; +} + export function computeMessageDurationStart( messages: ReadonlyArray, ): Map { @@ -27,3 +33,21 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +export function isOmittableAssistantNoResponseMessage( + message: TimelineNoResponseAssistantMessage, + options: { + followsWorkRow: boolean; + hasTurnDiffSummary: boolean; + showCompletionDivider: boolean; + }, +): boolean { + return ( + message.role === "assistant" && + message.text.length === 0 && + !message.streaming && + options.followsWorkRow && + !options.hasTurnDiffSummary && + !options.showCompletionDivider + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e694faa0f2..d7bb7b962e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -96,4 +96,74 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); }); + + it("combines tool calls across an empty assistant no-response gap", 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} + />, + ); + + expect(markup).not.toContain("(empty response)"); + expect(markup).toContain("Read files"); + expect(markup).toContain("Read README"); + expect(markup.match(/data-timeline-row-kind="work"/g)).toHaveLength(1); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..c450cc26b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -41,7 +41,11 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + isOmittableAssistantNoResponseMessage, + normalizeCompactToolLabel, +} from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -151,9 +155,24 @@ export const MessagesTimeline = memo(function MessagesTimeline({ let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; + if (!nextEntry) break; + if (nextEntry.kind === "work") { + groupedEntries.push(nextEntry.entry); + cursor += 1; + continue; + } + if ( + nextEntry.kind === "message" && + isOmittableAssistantNoResponseMessage(nextEntry.message, { + followsWorkRow: true, + hasTurnDiffSummary: turnDiffSummaryByAssistantMessageId.has(nextEntry.message.id), + showCompletionDivider: completionDividerBeforeEntryId === nextEntry.id, + }) + ) { + cursor += 1; + continue; + } + break; } nextRows.push({ kind: "work", @@ -175,6 +194,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ continue; } + const showCompletionDivider = + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id; + if ( + isOmittableAssistantNoResponseMessage(timelineEntry.message, { + followsWorkRow: nextRows[nextRows.length - 1]?.kind === "work", + hasTurnDiffSummary: turnDiffSummaryByAssistantMessageId.has(timelineEntry.message.id), + showCompletionDivider, + }) + ) { + continue; + } + nextRows.push({ kind: "message", id: timelineEntry.id, @@ -182,9 +214,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ message: timelineEntry.message, durationStart: durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id, + showCompletionDivider, }); } @@ -197,7 +227,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + }, [ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt, + turnDiffSummaryByAssistantMessageId, + ]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 73cb77eae9..364a201ca6 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -17,7 +17,9 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ ? "Codex" : status.provider === "claudeAgent" ? "Claude" - : status.provider; + : status.provider === "copilot" + ? "GitHub Copilot" + : status.provider; const defaultMessage = status.status === "error" ? `${providerLabel} provider is unavailable.` diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1694b374c8..5179e56a43 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -11,6 +11,10 @@ const MODEL_OPTIONS_BY_PROVIDER = { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], + copilot: [ + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { slug: "gpt-5.4", name: "GPT-5.4" }, + ], codex: [ { slug: "gpt-5-codex", name: "GPT-5 Codex" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, @@ -64,6 +68,7 @@ describe("ProviderModelPicker", () => { const text = document.body.textContent ?? ""; expect(text).toContain("Codex"); expect(text).toContain("Claude"); + expect(text).toContain("GitHub Copilot"); expect(text).not.toContain("Claude Sonnet 4.6"); }); } finally { diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 95f27f39cd..5ab55840ab 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,7 +17,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"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { @@ -31,6 +31,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, + copilot: GitHubIcon, cursor: CursorIcon, }; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 139876d6fa..ad86e43093 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -146,4 +146,42 @@ describe("getComposerProviderState", () => { modelOptionsForDispatch: undefined, }); }); + + it("keeps Copilot reasoning unset by default", () => { + const state = getComposerProviderState({ + provider: "copilot", + model: "gpt-5.4", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "copilot", + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("preserves an explicit Copilot reasoning effort in dispatch options", () => { + const state = getComposerProviderState({ + provider: "copilot", + model: "gpt-5.4", + prompt: "", + modelOptions: { + copilot: { + reasoningEffort: "medium", + }, + }, + }); + + expect(state).toEqual({ + provider: "copilot", + promptEffort: "medium", + modelOptionsForDispatch: { + copilot: { + reasoningEffort: "medium", + }, + }, + }); + }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index c1ad0156ad..140aae8748 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -2,6 +2,7 @@ import { type ModelSlug, type ProviderKind, type ProviderModelOptions, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { @@ -9,12 +10,14 @@ import { getReasoningEffortOptions, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, + normalizeCopilotModelOptions, normalizeCodexModelOptions, resolveReasoningEffortForProvider, supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; +import { CopilotTraitsMenuContent, CopilotTraitsPicker } from "./CopilotTraitsPicker"; import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; export type ComposerProviderStateInput = { @@ -38,11 +41,13 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + runtimeModels: ReadonlyArray | undefined; onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + runtimeModels: ReadonlyArray | undefined; onPromptChange: (prompt: string) => void; }) => ReactNode; }; @@ -104,6 +109,28 @@ const composerProviderRegistry: Record = { ), }, + copilot: { + getState: ({ modelOptions }) => { + const promptEffort = + resolveReasoningEffortForProvider("copilot", modelOptions?.copilot?.reasoningEffort) ?? + null; + const normalizedCopilotOptions = normalizeCopilotModelOptions(modelOptions?.copilot); + + return { + provider: "copilot", + promptEffort, + modelOptionsForDispatch: normalizedCopilotOptions + ? { copilot: normalizedCopilotOptions } + : undefined, + }; + }, + renderTraitsMenuContent: ({ threadId, model, runtimeModels }) => ( + + ), + renderTraitsPicker: ({ threadId, model, runtimeModels }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -114,11 +141,13 @@ export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + runtimeModels: ReadonlyArray | undefined; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + runtimeModels: input.runtimeModels, onPromptChange: input.onPromptChange, }); } @@ -127,11 +156,13 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + runtimeModels: ReadonlyArray | undefined; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + runtimeModels: input.runtimeModels, onPromptChange: input.onPromptChange, }); } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e1c3c0b5cd..4d2855c62e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -339,7 +339,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( @@ -356,6 +356,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" || @@ -406,13 +410,22 @@ function normalizeProviderModelOptions( ...(claudeFastMode ? { fastMode: true } : {}), } : undefined; + const copilotReasoningEffort: CodexReasoningEffort | undefined = + copilotCandidate?.reasoningEffort === "low" || + copilotCandidate?.reasoningEffort === "medium" || + copilotCandidate?.reasoningEffort === "high" || + copilotCandidate?.reasoningEffort === "xhigh" + ? copilotCandidate.reasoningEffort + : undefined; + const copilot = copilotReasoningEffort ? { reasoningEffort: copilotReasoningEffort } : undefined; - if (!codex && !claude) { + if (!codex && !claude && !copilot) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(copilot ? { copilot } : {}), }; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..b477a9e1b5 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -65,6 +65,7 @@ function SettingsRouteView() { >({ codex: "", claudeAgent: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -72,6 +73,8 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const copilotCliPath = settings.copilotCliPath; + const copilotConfigDir = settings.copilotConfigDir; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -343,6 +346,68 @@ function SettingsRouteView() { +
+
+

GitHub Copilot

+

+ These overrides apply to new Copilot sessions and let you point T3 Code at a + specific CLI install or config directory. +

+
+ +
+ + + + +
+
+

CLI source

+

+ {copilotCliPath || "Bundled/PATH"} +

+
+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4a113adebe..2af9138018 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1084,12 +1084,14 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { + it("advertises Codex, Claude, and GitHub Copilot 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: "GitHub Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ @@ -1097,6 +1099,11 @@ describe("PROVIDER_OPTIONS", () => { label: "Claude", available: true, }); + expect(copilot).toEqual({ + value: "copilot", + label: "GitHub 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 7c3ea96e65..40e05b6aa9 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: "GitHub Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 8269b30a65..4e3aae384c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -188,7 +188,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"; @@ -198,7 +198,11 @@ function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") { + if ( + input.sessionProviderName === "codex" || + input.sessionProviderName === "claudeAgent" || + input.sessionProviderName === "copilot" + ) { return input.sessionProviderName; } return inferProviderForModel(input.model); diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf25..1a5d3c04ec 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -7,7 +7,7 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1"); describe("terminalStateStore actions", () => { beforeEach(() => { - if (typeof localStorage !== "undefined") { + if (typeof localStorage !== "undefined" && typeof localStorage.clear === "function") { localStorage.clear(); } useTerminalStateStore.setState({ terminalStateByThreadId: {} }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index b2cea6d560..348c280ad3 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -14,6 +14,7 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; +import { createMemoryStorage } from "./lib/storage"; interface ThreadTerminalState { terminalOpen: boolean; @@ -26,6 +27,13 @@ interface ThreadTerminalState { } const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +const terminalStateStorage = + typeof localStorage !== "undefined" && + typeof localStorage.getItem === "function" && + typeof localStorage.setItem === "function" && + typeof localStorage.removeItem === "function" + ? localStorage + : createMemoryStorage(); function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; @@ -542,7 +550,7 @@ export const useTerminalStateStore = create()( { name: TERMINAL_STATE_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => terminalStateStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }), diff --git a/bun.lock b/bun.lock index c20107dc89..e0a992fd37 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", @@ -358,6 +360,22 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@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=="], @@ -1820,7 +1838,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=="], @@ -1992,6 +2010,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 dac8ce6ae0..08fbca6d5b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -20,9 +20,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; @@ -45,6 +51,16 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], + copilot: [ + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { slug: "claude-opus-4.6", name: "Claude Opus 4.6" }, + { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 (Fast Mode)" }, + { slug: "gemini-3.0", name: "Gemini 3.0 Pro" }, + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -54,6 +70,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + copilot: "claude-sonnet-4.6", }; // Backward compatibility for existing Codex-only call sites. @@ -83,14 +100,30 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", claudeAgent: "high", -} as const satisfies Record; + copilot: null, +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..6205ae10ac 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -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", @@ -48,16 +48,25 @@ export const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), }); +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; export const ClaudeProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), permissionMode: Schema.optional(TrimmedNonEmptyString), maxThinkingTokens: Schema.optional(NonNegativeInt), }); +export type ClaudeProviderStartOptions = typeof ClaudeProviderStartOptions.Type; + +export const CopilotProviderStartOptions = Schema.Struct({ + cliPath: Schema.optional(TrimmedNonEmptyString), + configDir: Schema.optional(TrimmedNonEmptyString), +}); +export type CopilotProviderStartOptions = typeof CopilotProviderStartOptions.Type; export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeAgent: Schema.optional(ClaudeProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2fec889b6d..4959a034f1 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -22,6 +22,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/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..3f238f18ae 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; +import { CODEX_REASONING_EFFORT_OPTIONS } from "./model"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -33,6 +34,19 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; +export const ServerProviderModelReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); +export type ServerProviderModelReasoningEffort = typeof ServerProviderModelReasoningEffort.Type; + +export const ServerProviderModel = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + supportsReasoningEffort: Schema.Boolean, + supportedReasoningEfforts: Schema.optional(Schema.Array(ServerProviderModelReasoningEffort)), + defaultReasoningEffort: Schema.optional(ServerProviderModelReasoningEffort), + billingMultiplier: Schema.optional(Schema.Number), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + export const ServerProviderStatus = Schema.Struct({ provider: ProviderKind, status: ServerProviderStatusState, @@ -40,6 +54,7 @@ export const ServerProviderStatus = Schema.Struct({ authStatus: ServerProviderAuthStatus, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + models: Schema.optional(Schema.Array(ServerProviderModel)), }); export type ServerProviderStatus = typeof ServerProviderStatus.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2c8aaf1986..4645aa8f1d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,6 +10,7 @@ import { import { applyClaudePromptEffortPrefix, + findProviderRuntimeModel, getEffectiveClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, @@ -18,8 +19,10 @@ import { inferProviderForModel, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, + normalizeCopilotModelOptions, normalizeCodexModelOptions, normalizeModelSlug, + resolveCopilotReasoningCapability, resolveReasoningEffortForProvider, resolveSelectableModel, resolveModelSlug, @@ -58,6 +61,8 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); + expect(normalizeModelSlug("sonnet", "copilot")).toBe("claude-sonnet-4.6"); + expect(normalizeModelSlug("gemini", "copilot")).toBe("gemini-3.0"); }); }); @@ -192,6 +197,8 @@ describe("inferProviderForModel", () => { it("detects known provider model slugs", () => { expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); + expect(inferProviderForModel("claude-sonnet-4.6")).toBe("copilot"); + expect(inferProviderForModel("gemini-3.0")).toBe("copilot"); expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); }); @@ -268,6 +275,78 @@ describe("normalizeCodexModelOptions", () => { }); }); +describe("normalizeCopilotModelOptions", () => { + it("keeps non-default reasoning values", () => { + expect(normalizeCopilotModelOptions({ reasoningEffort: "medium" })).toEqual({ + reasoningEffort: "medium", + }); + }); + + it("drops missing or invalid reasoning values", () => { + expect(normalizeCopilotModelOptions(undefined)).toBeUndefined(); + expect(normalizeCopilotModelOptions({ reasoningEffort: "max" as never })).toBeUndefined(); + }); +}); + +describe("findProviderRuntimeModel", () => { + it("matches runtime models using provider-specific aliases", () => { + expect( + findProviderRuntimeModel("copilot", "sonnet", [ + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", supportsReasoningEffort: false }, + ]), + ).toMatchObject({ + id: "claude-sonnet-4.6", + }); + }); +}); + +describe("resolveCopilotReasoningCapability", () => { + it("uses runtime model metadata when available", () => { + expect( + resolveCopilotReasoningCapability("gpt-5.4", [ + { + id: "gpt-5.4", + name: "GPT-5.4", + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium"], + defaultReasoningEffort: "medium", + }, + ]), + ).toEqual({ + supported: true, + options: ["low", "medium"], + defaultReasoningEffort: "medium", + source: "runtime", + }); + }); + + it("falls back to the static allowlist when runtime metadata is unavailable", () => { + expect(resolveCopilotReasoningCapability("gpt-5.4")).toEqual({ + supported: true, + options: ["xhigh", "high", "medium", "low"], + defaultReasoningEffort: null, + source: "static", + }); + }); + + it("returns no reasoning controls for unsupported runtime models", () => { + expect( + resolveCopilotReasoningCapability("claude-haiku-4.5", [ + { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + supportsReasoningEffort: false, + }, + ]), + ).toEqual({ + supported: false, + options: [], + defaultReasoningEffort: null, + source: "runtime", + }); + }); +}); + describe("normalizeClaudeModelOptions", () => { it("drops unsupported fast mode and max effort for Sonnet", () => { expect( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2d46320753..43df09dad7 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -8,21 +8,45 @@ import { REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeModelOptions, type ClaudeCodeEffort, + type CopilotModelOptions, type CodexModelOptions, type CodexReasoningEffort, type ModelSlug, type ProviderReasoningEffort, type ProviderKind, + type ServerProviderModel, } from "@t3tools/contracts"; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), }; +const COPILOT_INFERABLE_MODEL_SLUGS: ReadonlySet = new Set( + MODEL_OPTIONS_BY_PROVIDER.copilot + .map((option) => option.slug) + .filter( + (slug) => + !MODEL_SLUG_SET_BY_PROVIDER.codex.has(slug) && + !MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(slug), + ), +); const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; +const COPILOT_STATIC_REASONING_MODEL_SLUGS: ReadonlySet = new Set([ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", +]); + +export interface CopilotReasoningCapability { + readonly supported: boolean; + readonly options: ReadonlyArray; + readonly defaultReasoningEffort: CodexReasoningEffort | null; + readonly source: "runtime" | "static" | "none"; +} export interface SelectableModelOption { slug: string; @@ -37,6 +61,32 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { return DEFAULT_MODEL_BY_PROVIDER[provider]; } +export function findProviderRuntimeModel( + provider: ProviderKind, + model: string | null | undefined, + runtimeModels: ReadonlyArray | null | undefined, +): ServerProviderModel | null { + if (!runtimeModels || runtimeModels.length === 0 || typeof model !== "string") { + return null; + } + + const trimmed = model.trim(); + if (!trimmed) { + return null; + } + + const normalized = normalizeModelSlug(trimmed, provider); + return ( + runtimeModels.find((candidate) => { + if (candidate.id === trimmed) { + return true; + } + const normalizedCandidate = normalizeModelSlug(candidate.id, provider); + return normalized !== null && normalizedCandidate === normalized; + }) ?? null + ); +} + export function supportsClaudeFastMode(model: string | null | undefined): boolean { return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; } @@ -140,6 +190,19 @@ export function inferProviderForModel( model: string | null | undefined, fallback: ProviderKind = "codex", ): ProviderKind { + if (typeof model === "string") { + const trimmed = model.trim(); + if (trimmed) { + const exactMatchProviders = (["claudeAgent", "codex", "copilot"] as const).filter( + (provider) => MODEL_SLUG_SET_BY_PROVIDER[provider].has(trimmed as ModelSlug), + ); + const [exactMatchProvider] = exactMatchProviders; + if (exactMatchProvider && exactMatchProviders.length === 1) { + return exactMatchProvider; + } + } + } + const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { return "claudeAgent"; @@ -150,6 +213,11 @@ export function inferProviderForModel( return "codex"; } + const normalizedCopilot = normalizeModelSlug(model, "copilot"); + if (normalizedCopilot && COPILOT_INFERABLE_MODEL_SLUGS.has(normalizedCopilot)) { + return "copilot"; + } + return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; } @@ -158,6 +226,7 @@ export function getReasoningEffortOptions( provider: "claudeAgent", model?: string | null | undefined, ): ReadonlyArray; +export function getReasoningEffortOptions(provider: "copilot"): ReadonlyArray; export function getReasoningEffortOptions( provider?: ProviderKind, model?: string | null | undefined, @@ -178,12 +247,58 @@ export function getReasoningEffortOptions( return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; } +export function resolveCopilotReasoningCapability( + model: string | null | undefined, + runtimeModels?: ReadonlyArray | null, +): CopilotReasoningCapability { + const runtimeModel = findProviderRuntimeModel("copilot", model, runtimeModels); + if (runtimeModel) { + if (!runtimeModel.supportsReasoningEffort) { + return { + supported: false, + options: [], + defaultReasoningEffort: null, + source: "runtime", + }; + } + + const options = + runtimeModel.supportedReasoningEfforts && runtimeModel.supportedReasoningEfforts.length > 0 + ? runtimeModel.supportedReasoningEfforts + : CODEX_REASONING_EFFORT_OPTIONS; + return { + supported: options.length > 0, + options, + defaultReasoningEffort: runtimeModel.defaultReasoningEffort ?? null, + source: "runtime", + }; + } + + const normalized = normalizeModelSlug(model, "copilot"); + if (normalized && COPILOT_STATIC_REASONING_MODEL_SLUGS.has(normalized)) { + return { + supported: true, + options: CODEX_REASONING_EFFORT_OPTIONS, + defaultReasoningEffort: null, + source: "static", + }; + } + + return { + supported: false, + options: [], + defaultReasoningEffort: null, + source: "none", + }; +} + export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; -export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; +export function getDefaultReasoningEffort(provider: "copilot"): null; +export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort | null; export function getDefaultReasoningEffort( provider: ProviderKind = "codex", -): ProviderReasoningEffort { +): ProviderReasoningEffort | null { return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; } @@ -195,6 +310,10 @@ export function resolveReasoningEffortForProvider( provider: "claudeAgent", effort: string | null | undefined, ): ClaudeCodeEffort | null; +export function resolveReasoningEffortForProvider( + provider: "copilot", + effort: string | null | undefined, +): CodexReasoningEffort | null; export function resolveReasoningEffortForProvider( provider: ProviderKind, effort: string | null | undefined, @@ -240,6 +359,16 @@ export function normalizeCodexModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeCopilotModelOptions( + modelOptions: CopilotModelOptions | null | undefined, +): CopilotModelOptions | undefined { + const reasoningEffort = resolveReasoningEffortForProvider( + "copilot", + modelOptions?.reasoningEffort, + ); + return reasoningEffort ? { reasoningEffort } : undefined; +} + export function normalizeClaudeModelOptions( model: string | null | undefined, modelOptions: ClaudeModelOptions | null | undefined,