From 1cc00a5993571bbb06ff0037d193602717b017c7 Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 25 Mar 2026 10:38:16 +0100 Subject: [PATCH 1/5] Add GitHub Copilot as a first-class provider --- apps/server/package.json | 2 + .../provider/Layers/CopilotAdapter.test.ts | 833 +++++++ .../src/provider/Layers/CopilotAdapter.ts | 2024 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 100 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../provider/Layers/copilotCliPath.test.ts | 102 + .../src/provider/Layers/copilotCliPath.ts | 176 ++ .../Layers/copilotTurnTracking.test.ts | 60 + .../provider/Layers/copilotTurnTracking.ts | 70 + .../src/provider/Services/CopilotAdapter.ts | 12 + apps/server/src/serverLayers.ts | 5 + apps/web/src/appSettings.test.ts | 58 +- apps/web/src/appSettings.ts | 31 +- apps/web/src/components/ChatView.tsx | 57 +- .../chat/ProviderModelPicker.browser.tsx | 5 + .../components/chat/ProviderModelPicker.tsx | 11 +- .../components/chat/TraitsPicker.browser.tsx | 2 +- apps/web/src/components/chat/TraitsPicker.tsx | 11 +- .../chat/composerProviderRegistry.tsx | 30 +- apps/web/src/composerDraftStore.ts | 126 +- apps/web/src/routes/_chat.settings.tsx | 74 +- apps/web/src/session-logic.test.ts | 9 +- apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 2 +- apps/web/src/terminalStateStore.test.ts | 30 +- apps/web/src/terminalStateStore.ts | 24 +- bun.lock | 22 +- packages/contracts/src/model.ts | 119 + packages/contracts/src/orchestration.ts | 23 +- packages/contracts/src/providerRuntime.ts | 2 + packages/shared/src/model.ts | 18 + 33 files changed, 3960 insertions(+), 107 deletions(-) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.ts create mode 100644 apps/server/src/provider/Layers/copilotCliPath.test.ts create mode 100644 apps/server/src/provider/Layers/copilotCliPath.ts create mode 100644 apps/server/src/provider/Layers/copilotTurnTracking.test.ts create mode 100644 apps/server/src/provider/Layers/copilotTurnTracking.ts create mode 100644 apps/server/src/provider/Services/CopilotAdapter.ts diff --git a/apps/server/package.json b/apps/server/package.json index ea818b7d3e..78d0664e29 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.10", + "@github/copilot-sdk": "0.2.0", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts new file mode 100644 index 0000000000..7a81fcc9bb --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,833 @@ +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 modelSwitchToImpl = vi.fn( + async ({ modelId }: { modelId: string; reasoningEffort?: string }) => ({ + modelId, + }), + ); + + public readonly modeSetImpl = vi.fn( + async ({ mode }: { mode: "interactive" | "plan" | "autopilot" }) => ({ + mode, + }), + ); + + public readonly planReadImpl = vi.fn( + async (): Promise<{ + exists: boolean; + content: string | null; + path: string | null; + }> => ({ + exists: false, + content: null, + path: null, + }), + ); + + public readonly sendImpl = vi.fn( + async (_options: { prompt: string; attachments?: unknown; mode?: string }) => "message-1", + ); + + public readonly abortImpl = vi.fn(async () => undefined); + public readonly disconnectImpl = vi.fn(async () => undefined); + public readonly destroyImpl = vi.fn(async () => undefined); + public readonly getMessagesImpl = vi.fn(async () => [] as SessionEvent[]); + + private readonly handlers = new Set<(event: SessionEvent) => void>(); + + public readonly rpc = { + model: { + switchTo: this.modelSwitchToImpl, + }, + mode: { + set: this.modeSetImpl, + }, + plan: { + read: this.planReadImpl, + }, + }; + + constructor(sessionId: string) { + this.sessionId = sessionId; + } + + on(handler: (event: SessionEvent) => void) { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + send(options: { prompt: string; attachments?: unknown; mode?: string }) { + return this.sendImpl(options); + } + + abort() { + return this.abortImpl(); + } + + disconnect() { + return this.disconnectImpl(); + } + + destroy() { + return this.destroyImpl(); + } + + getMessages() { + return this.getMessagesImpl(); + } + + emit(event: SessionEvent) { + for (const handler of this.handlers) { + handler(event); + } + } +} + +class FakeCopilotClient { + public readonly startImpl = vi.fn(async () => undefined); + public readonly listModelsImpl = vi.fn(async () => []); + public readonly createSessionImpl = vi.fn(async (_config: unknown) => this.session); + public readonly resumeSessionImpl = vi.fn( + async (_sessionId: string, _config: unknown) => this.session, + ); + public readonly stopImpl = vi.fn(async () => [] as Error[]); + + constructor(private readonly session: FakeCopilotSession) {} + + start() { + return this.startImpl(); + } + + listModels() { + return this.listModelsImpl(); + } + + createSession(config: unknown) { + return this.createSessionImpl(config); + } + + resumeSession(sessionId: string, config: unknown) { + return this.resumeSessionImpl(sessionId, config); + } + + stop() { + return this.stopImpl(); + } +} + +function makeModelInfo(input: { + id: string; + name: string; + supportedReasoningEfforts?: ReadonlyArray<"low" | "medium" | "high" | "xhigh">; + defaultReasoningEffort?: "low" | "medium" | "high" | "xhigh"; +}) { + return input as unknown as import("@github/copilot-sdk").ModelInfo; +} + +function makeCopilotModelSelection( + model: string, + reasoningEffort?: "low" | "medium" | "high" | "xhigh", +) { + return { + provider: "copilot" as const, + model, + ...(reasoningEffort ? { options: { reasoningEffort } } : {}), + }; +} + +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"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "high"), + runtimeMode: "full-access", + }); + + assert.equal(reasoningClient.startImpl.mock.calls.length, 1); + assert.equal(reasoningClient.listModelsImpl.mock.calls.length, 1); + const createdConfig = reasoningClient.createSessionImpl.mock.calls[0]?.[0] as Record< + string, + unknown + >; + assert.equal(createdConfig.model, "gpt-5.4"); + assert.equal(createdConfig.reasoningEffort, "high"); + assert.equal(createdConfig.sessionId, "t3code-copilot-thread-reasoning-start"); + assert.equal(createdConfig.streaming, true); + assert.equal(typeof createdConfig.onPermissionRequest, "function"); + assert.equal(typeof createdConfig.onUserInputRequest, "function"); + }), + ); + + it.effect("rejects a non-Copilot modelSelection", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-no-model"), + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "copilot", + operation: "startSession", + issue: "Expected modelSelection.provider 'copilot', received 'codex'.", + }), + ); + assert.equal(reasoningClient.startImpl.mock.calls.length, 0); + assert.equal(reasoningClient.listModelsImpl.mock.calls.length, 0); + assert.equal(reasoningClient.createSessionImpl.mock.calls.length, 0); + }), + ); + + it.effect("rejects unsupported reasoning effort for a valid model", () => + Effect.gen(function* () { + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium"], + }), + ] as never); + + const adapter = yield* CopilotAdapter; + const result = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-invalid"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "xhigh"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "copilot", + operation: "session.reasoningEffort", + issue: "GitHub Copilot model 'gpt-5.4' does not support reasoning effort 'xhigh'.", + }), + ); + assert.equal(reasoningClient.createSessionImpl.mock.calls.length, 0); + }), + ); + + it.effect("reconfigures the session when reasoning effort changes", () => + Effect.gen(function* () { + reasoningSession.modelSwitchToImpl.mockClear(); + reasoningSession.disconnectImpl.mockClear(); + reasoningSession.destroyImpl.mockClear(); + reasoningSession.sendImpl.mockClear(); + reasoningClient.startImpl.mockClear(); + reasoningClient.listModelsImpl.mockReset(); + reasoningClient.createSessionImpl.mockClear(); + reasoningClient.resumeSessionImpl.mockClear(); + reasoningClient.listModelsImpl.mockResolvedValue([ + makeModelInfo({ + id: "gpt-5.4", + name: "GPT-5.4", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }), + ] as never); + + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-reasoning-reconfigure"), + modelSelection: makeCopilotModelSelection("gpt-5.4", "high"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Switch effort", + modelSelection: makeCopilotModelSelection("gpt-5.4", "low"), + attachments: [], + }); + + assert.deepStrictEqual(reasoningSession.modelSwitchToImpl.mock.calls, [ + [{ modelId: "gpt-5.4", reasoningEffort: "low" }], + ]); + assert.equal(reasoningSession.disconnectImpl.mock.calls.length, 0); + assert.equal(reasoningSession.destroyImpl.mock.calls.length, 0); + assert.equal(reasoningClient.resumeSessionImpl.mock.calls.length, 0); + assert.equal(reasoningSession.sendImpl.mock.calls.length, 1); + }), + ); +}); + +const toolEventSession = new FakeCopilotSession("copilot-session-tool-events"); +const toolEventClient = new FakeCopilotClient(toolEventSession); +const toolEventLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => toolEventClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); + +toolEventLayer("CopilotAdapterLive tool event mapping", (it) => { + it.effect("maps Copilot tool events to canonical lifecycle item types", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-events"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Inspect and edit a file", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolEventSession.emit({ + id: "evt-tool-start-command", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-command", + toolName: "bash", + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-complete-command", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-command", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-command", + success: true, + result: { + content: "ok", + }, + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-start-write", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-command", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-write", + toolName: "write_file", + }, + } satisfies SessionEvent); + toolEventSession.emit({ + id: "evt-tool-complete-write", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-write", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-write", + success: true, + result: { + content: "done", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)).filter( + (event) => event.type === "item.started" || event.type === "item.completed", + ); + assert.deepStrictEqual( + events.map((event) => + "payload" in event && event.payload && typeof event.payload === "object" + ? { + type: event.type, + itemType: "itemType" in event.payload ? event.payload.itemType : undefined, + title: "title" in event.payload ? event.payload.title : undefined, + } + : { type: event.type, itemType: undefined, title: undefined }, + ), + [ + { type: "item.started", itemType: "command_execution", title: "Command run" }, + { type: "item.completed", itemType: "command_execution", title: "Command run" }, + { type: "item.started", itemType: "file_change", title: "File change" }, + { type: "item.completed", itemType: "file_change", title: "File change" }, + ], + ); + }), + ); +}); + +const toolTitleSession = new FakeCopilotSession("copilot-session-tool-titles"); +const toolTitleClient = new FakeCopilotClient(toolTitleSession); +const toolTitleLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => toolTitleClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); + +toolTitleLayer("CopilotAdapterLive tool titles", (it) => { + it.effect("uses specific titles for Copilot SDK read and search tools", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-titles"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Read and search files", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-view", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-view", + toolName: "view", + arguments: { + path: "README.md", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-view", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-view", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-view", + success: true, + result: { + content: "read ok", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-start-grep", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-view", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-grep", + toolName: "grep", + arguments: { + pattern: "Copilot", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-grep", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-grep", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-grep", + success: true, + result: { + content: "match", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-start-list-directory", + timestamp: new Date().toISOString(), + parentId: "evt-tool-complete-grep", + type: "tool.execution_start", + data: { + toolCallId: "tool-call-list-directory", + toolName: "list_directory", + arguments: { + path: ".", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-list-directory", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-list-directory", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-list-directory", + success: true, + result: { + content: "listed", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)).filter( + (event) => event.type === "item.started" || event.type === "item.completed", + ); + assert.deepStrictEqual( + events.map((event) => + "payload" in event && event.payload && typeof event.payload === "object" + ? { + type: event.type, + itemType: "itemType" in event.payload ? event.payload.itemType : undefined, + title: "title" in event.payload ? event.payload.title : undefined, + } + : { type: event.type, itemType: undefined, title: undefined }, + ), + [ + { type: "item.started", itemType: "dynamic_tool_call", title: "Read file" }, + { type: "item.completed", itemType: "dynamic_tool_call", title: "Read file" }, + { type: "item.started", itemType: "dynamic_tool_call", title: "Grep" }, + { type: "item.completed", itemType: "dynamic_tool_call", title: "Grep" }, + { + type: "item.started", + itemType: "dynamic_tool_call", + title: "List directory", + }, + { + type: "item.completed", + itemType: "dynamic_tool_call", + title: "List directory", + }, + ], + ); + }), + ); + + it.effect("uses detailedContent for completed tool detail and content for tool summary", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-detailed-content"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Inspect a diff", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-detailed", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-detailed", + toolName: "edit", + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-detailed", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-detailed", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-detailed", + success: true, + result: { + content: "Updated file", + detailedContent: "Updated file\n--- a/file.ts\n+++ b/file.ts\n+const value = 1;", + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + const summaryEvent = events.find((event) => event.type === "tool.summary"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal( + completedEvent.payload.detail, + "Updated file\n--- a/file.ts\n+++ b/file.ts\n+const value = 1;", + ); + } + + assert.equal(summaryEvent?.type, "tool.summary"); + if (summaryEvent?.type === "tool.summary") { + assert.equal(summaryEvent.payload.summary, "Updated file"); + } + }), + ); + + it.effect("maps diff-like Copilot detailedContent to a file change completion", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-diff-file-change"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Apply a patch", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-diff", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-diff", + toolName: "view", + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-diff", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-diff", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-diff", + success: true, + result: { + content: "Updated file", + detailedContent: [ + "diff --git a/apps/web/src/foo.ts b/apps/web/src/foo.ts", + "--- a/apps/web/src/foo.ts", + "+++ b/apps/web/src/foo.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"), + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal(completedEvent.payload.itemType, "file_change"); + assert.equal(completedEvent.payload.title, "File change"); + assert.deepStrictEqual( + (completedEvent.payload.data as { changes?: Array<{ path: string }> }).changes, + [{ path: "apps/web/src/foo.ts" }], + ); + } + }), + ); +}); + +afterAll(() => { + void modeSession.disconnect(); + void modeClient.stop(); + void planSession.disconnect(); + void planClient.stop(); + void reasoningSession.disconnect(); + void reasoningClient.stop(); + void toolEventSession.disconnect(); + void toolEventClient.stop(); + void toolTitleSession.disconnect(); + void toolTitleClient.stop(); +}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 0000000000..2959abdeec --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,2024 @@ +import { randomUUID } from "node:crypto"; + +import { + type CopilotModelSelection, + type CodexReasoningEffort, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ThreadTokenUsageSnapshot, + type ToolLifecycleItemType, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + CopilotClient, + type CopilotClientOptions, + type 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; + toolItemTypesByCallId: Map; + toolTitlesByCallId: Map; + pendingApprovalResolvers: Map; + pendingUserInputResolvers: Map; + unsubscribe: () => void; +} + +interface CopilotSessionHandle { + readonly sessionId: string; + readonly rpc: { + readonly model: { + switchTo(input: { modelId: string; reasoningEffort?: string }): Promise<{ + modelId?: string; + }>; + }; + readonly mode: { + set(input: { mode: "interactive" | "plan" | "autopilot" }): Promise<{ + mode: "interactive" | "plan" | "autopilot"; + }>; + }; + readonly plan: { + read(): Promise<{ + exists: boolean; + content: string | null; + path: string | null; + }>; + }; + }; + disconnect?(): Promise; + destroy(): Promise; + on(handler: (event: SessionEvent) => void): () => void; + send(options: { prompt: string; attachments?: unknown; mode?: string }): Promise; + abort(): Promise; + getMessages(): Promise; +} + +interface CopilotClientHandle { + start(): Promise; + listModels(): Promise; + createSession( + config: Parameters[0], + ): Promise; + resumeSession( + sessionId: string, + config: Parameters[1], + ): Promise; + stop(): Promise; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function makeEventId(prefix: string) { + return EventId.makeUnsafe(`${prefix}-${randomUUID()}`); +} + +function toTurnId(value: string | undefined): TurnId | undefined { + if (!value || value.trim().length === 0) return undefined; + return TurnId.makeUnsafe(value); +} + +function toRuntimeItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeItemId.makeUnsafe(value); +} + +function toProviderItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return ProviderItemId.makeUnsafe(value); +} + +function toRuntimeRequestId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeRequestId.makeUnsafe(value); +} + +function toRuntimeTaskId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeTaskId.makeUnsafe(value); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function 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 getCopilotModelSelection( + input: Pick, +): CopilotModelSelection | undefined { + return input.modelSelection?.provider === PROVIDER ? input.modelSelection : undefined; +} + +function getCopilotReasoningEffort( + modelSelection: CopilotModelSelection | undefined, +): CodexReasoningEffort | undefined { + return modelSelection?.options?.reasoningEffort; +} + +function normalizeCopilotSessionTokenUsage( + usage: Extract["data"], +): ThreadTokenUsageSnapshot | undefined { + if (usage.currentTokens <= 0) { + return undefined; + } + + return { + usedTokens: usage.currentTokens, + lastUsedTokens: usage.currentTokens, + ...(usage.tokenLimit > 0 ? { maxTokens: usage.tokenLimit } : {}), + compactsAutomatically: true, + }; +} + +function normalizeCopilotAssistantTokenUsage( + usage: Extract["data"], +): ThreadTokenUsageSnapshot | undefined { + const inputTokens = usage.inputTokens ?? 0; + const cachedInputTokens = usage.cacheReadTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + const usedTokens = inputTokens + cachedInputTokens + outputTokens; + + if (usedTokens <= 0) { + return undefined; + } + + const totalProcessedTokens = usedTokens + (usage.cacheWriteTokens ?? 0); + return { + usedTokens, + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), + ...(inputTokens > 0 ? { inputTokens } : {}), + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + ...(outputTokens > 0 ? { outputTokens } : {}), + lastUsedTokens: usedTokens, + ...(inputTokens > 0 ? { lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens > 0 ? { lastCachedInputTokens: cachedInputTokens } : {}), + ...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}), + ...(usage.duration !== undefined && usage.duration >= 0 ? { durationMs: usage.duration } : {}), + compactsAutomatically: true, + }; +} + +function extractResumeSessionId(resumeCursor: unknown): string | undefined { + if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { + return resumeCursor.trim(); + } + const record = asRecord(resumeCursor); + const sessionId = normalizeString(record?.sessionId); + return sessionId; +} + +function toCopilotSessionMode(interactionMode: "default" | "plan"): "interactive" | "plan" { + return interactionMode === "plan" ? "plan" : "interactive"; +} + +function toInteractionMode(mode: string): "default" | "plan" { + return mode === "plan" ? "plan" : "default"; +} + +function approvalDecisionToPermissionResult( + decision: ProviderApprovalDecision, +): PermissionRequestResult { + switch (decision) { + case "accept": + case "acceptForSession": + return { kind: "approved" }; + case "decline": + case "cancel": + default: + return { kind: "denied-interactively-by-user" }; + } +} + +function requestTypeFromPermissionRequest(request: PermissionRequest) { + switch (request.kind) { + case "shell": + return "command_execution_approval" as const; + case "write": + return "file_change_approval" as const; + case "read": + return "file_read_approval" as const; + case "custom-tool": + return classifyToolRequestType( + trimToUndefined(String(request.toolTitle ?? request.toolName ?? "")) ?? "tool", + ); + case "mcp": + return classifyToolRequestType( + trimToUndefined( + String(request.toolTitle ?? request.toolName ?? request.mcpToolName ?? ""), + ) ?? "tool", + ); + case "url": + return "dynamic_tool_call" as const; + default: + return "unknown" as const; + } +} + +function normalizeToolName(toolName: string): string { + return toolName.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function toolDisplayTitle(toolName: string): string | undefined { + const normalized = normalizeToolName(toolName); + + if ( + normalized === "view" || + normalized === "read" || + normalized === "read file" || + normalized === "read files" || + normalized === "view file" || + normalized === "view files" + ) { + return "Read file"; + } + + if (normalized === "grep") { + return "Grep"; + } + + if (normalized === "glob") { + return "Glob"; + } + + if ( + normalized === "search code" || + normalized === "search file" || + normalized === "search files" || + normalized === "find file" || + normalized === "find files" + ) { + return "Search files"; + } + + if (normalized === "list directory" || normalized === "list directories") { + return "List directory"; + } + + return undefined; +} + +function isReadOnlyToolName(toolName: string): boolean { + const normalized = normalizeToolName(toolName); + return ( + normalized === "read" || + normalized.startsWith("read ") || + normalized.includes("read file") || + normalized === "view" || + normalized.startsWith("view ") || + normalized.includes("view file") || + normalized === "grep" || + normalized === "glob" || + normalized.includes("search code") || + normalized.includes("search file") || + normalized.includes("find file") || + normalized.includes("list directory") + ); +} + +function classifyToolItemType(toolName: string): ToolLifecycleItemType { + const normalized = normalizeToolName(toolName); + + if ( + normalized.includes("agent") || + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") + ) { + return "collab_agent_tool_call"; + } + + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + + if (normalized.includes("websearch") || normalized.includes("web search")) { + return "web_search"; + } + + if (normalized.includes("image")) { + return "image_view"; + } + + if (isReadOnlyToolName(toolName)) { + return "dynamic_tool_call"; + } + + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") || + normalized.includes("rename") || + normalized.includes("move") || + normalized.includes("modify") || + normalized.includes("apply") + ) { + return "file_change"; + } + + return "dynamic_tool_call"; +} + +function classifyToolRequestType( + toolName: string, +): + | "command_execution_approval" + | "file_change_approval" + | "file_read_approval" + | "dynamic_tool_call" { + if (isReadOnlyToolName(toolName)) { + return "file_read_approval"; + } + + const itemType = classifyToolItemType(toolName); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : "dynamic_tool_call"; +} + +function requestDetailFromPermissionRequest(request: PermissionRequest): string | undefined { + switch (request.kind) { + case "shell": + return trimToUndefined(String(request.fullCommandText ?? "")); + case "write": + return trimToUndefined(String(request.fileName ?? request.intention ?? "")); + case "read": + return trimToUndefined(String(request.path ?? request.intention ?? "")); + case "mcp": + return trimToUndefined(String(request.toolTitle ?? request.toolName ?? "")); + case "url": + return trimToUndefined(String(request.url ?? request.intention ?? "")); + case "custom-tool": + return trimToUndefined(String(request.toolName ?? request.toolDescription ?? "")); + default: + return undefined; + } +} + +function itemTypeFromToolEvent(event: Extract) { + return event.data.mcpToolName ? "mcp_tool_call" : classifyToolItemType(event.data.toolName); +} + +function toolTitleFromItemType(itemType: ToolLifecycleItemType, toolName?: string): string { + if (toolName && itemType === "dynamic_tool_call") { + const dynamicToolTitle = toolDisplayTitle(toolName); + if (dynamicToolTitle) { + return dynamicToolTitle; + } + } + + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "collab_agent_tool_call": + return "Subagent task"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + case "dynamic_tool_call": + return "Tool call"; + } +} + +function toolDetailFromEvent(data: { + readonly toolName?: string; + readonly mcpToolName?: string; + readonly mcpServerName?: string; +}) { + return trimToUndefined( + [data.mcpServerName, data.mcpToolName ?? data.toolName].filter(Boolean).join(" / "), + ); +} + +function toolResultSummaryContent( + result: { readonly content?: string } | undefined, +): string | undefined { + return trimToUndefined(result?.content); +} + +function toolResultDetailContent( + result: + | { + readonly content?: string; + readonly detailedContent?: string; + } + | undefined, +): string | undefined { + return trimToUndefined(result?.detailedContent) ?? trimToUndefined(result?.content); +} + +function looksLikeDiffDetail(detail: string | undefined): boolean { + if (!detail) { + return false; + } + const normalized = detail.trim(); + return ( + normalized.startsWith("diff --git ") || + (/^---\s/m.test(normalized) && /^\+\+\+\s/m.test(normalized)) + ); +} + +function normalizeDiffPath(value: string): string { + if (value === "/dev/null") { + return value; + } + if (value.startsWith("a/") || value.startsWith("b/")) { + return value.slice(2); + } + return value; +} + +function extractChangedFilesFromDiff(detail: string | undefined): string[] { + if (!looksLikeDiffDetail(detail)) { + return []; + } + const normalizedDetail = detail?.trim(); + if (!normalizedDetail) { + return []; + } + + const changedFiles: string[] = []; + const seen = new Set(); + const pushChangedFile = (value: string | undefined) => { + const normalized = trimToUndefined(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + changedFiles.push(normalized); + }; + + for (const match of normalizedDetail.matchAll( + /^diff --git\s+(?\S+)\s+(?\S+)$/gm, + )) { + const beforePath = normalizeDiffPath(match.groups?.before ?? ""); + const afterPath = normalizeDiffPath(match.groups?.after ?? ""); + pushChangedFile(afterPath !== "/dev/null" ? afterPath : beforePath); + } + + if (changedFiles.length > 0) { + return changedFiles; + } + + for (const match of normalizedDetail.matchAll(/^(?:---|\+\+\+)\s+(?\S+)$/gm)) { + const path = normalizeDiffPath(match.groups?.path ?? ""); + if (path === "/dev/null") { + continue; + } + pushChangedFile(path); + } + + return changedFiles; +} + +function withRefs(input: { + readonly threadId: ThreadId; + readonly eventId: EventId; + readonly createdAt: string; + readonly turnId: TurnId | undefined; + readonly providerTurnId?: TurnId | undefined; + readonly itemId: string | undefined; + readonly requestId: string | undefined; + readonly rawMethod: string | undefined; + readonly rawPayload: unknown; +}): Omit { + const providerTurnId = input.providerTurnId ?? input.turnId; + const runtimeItemId = toRuntimeItemId(input.itemId); + const runtimeRequestId = toRuntimeRequestId(input.requestId); + const providerItemId = toProviderItemId(input.itemId); + const providerRequestId = trimToUndefined(input.requestId); + return { + eventId: input.eventId, + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(runtimeItemId ? { itemId: runtimeItemId } : {}), + ...(runtimeRequestId ? { requestId: runtimeRequestId } : {}), + ...(providerTurnId || providerItemId || providerRequestId + ? { + providerRefs: { + ...(providerTurnId ? { providerTurnId } : {}), + ...(providerItemId ? { providerItemId } : {}), + ...(providerRequestId ? { providerRequestId } : {}), + }, + } + : {}), + raw: { + source: input.rawMethod ? "copilot.sdk.session-event" : "copilot.sdk.synthetic", + ...(input.rawMethod ? { method: input.rawMethod } : {}), + payload: input.rawPayload, + }, + }; +} + +function mapHistoryToTurns( + threadId: ThreadId, + events: ReadonlyArray, +): ProviderThreadSnapshot { + const turns: Array = []; + let current: { id: TurnId; items: Array } | undefined; + + for (const event of events) { + if (event.type === "assistant.turn_start") { + current = { + id: TurnId.makeUnsafe(event.data.turnId), + items: [event], + }; + turns.push(current); + continue; + } + + if (!current) { + continue; + } + + current.items.push(event); + if (isCopilotTurnTerminalEvent(event)) { + current = undefined; + } + } + + return { + threadId, + turns: turns.map((turn) => ({ + id: turn.id, + items: turn.items, + })), + }; +} + +function makeSyntheticEvent( + threadId: ThreadId, + type: ProviderRuntimeEvent["type"], + payload: ProviderRuntimeEvent["payload"], + extra?: { + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }, +): ProviderRuntimeEvent { + return { + ...withRefs({ + threadId, + eventId: makeEventId("copilot-synthetic"), + createdAt: new Date().toISOString(), + turnId: extra?.turnId, + itemId: extra?.itemId, + requestId: extra?.requestId, + rawMethod: undefined, + rawPayload: payload, + }), + type, + payload, + } as ProviderRuntimeEvent; +} + +function resolveUserInputAnswer( + pending: PendingUserInputRequest, + answers: ProviderUserInputAnswers, +): CopilotUserInputResponse { + const direct = answers[USER_INPUT_QUESTION_ID]; + const candidate = + typeof direct === "string" + ? direct + : Object.values(answers).find((value): value is string => typeof value === "string"); + const answer = trimToUndefined(candidate) ?? ""; + return { + answer, + wasFreeform: !pending.request.choices?.includes(answer), + }; +} + +function createSessionRecord(input: { + readonly threadId: ThreadId; + readonly client: CopilotClientHandle; + readonly 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, + toolItemTypesByCallId: new Map(), + 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": { + const usage = normalizeCopilotSessionTokenUsage(event.data); + if (!usage) { + return []; + } + return [ + { + ...base(), + type: "thread.token-usage.updated", + payload: { + usage, + }, + }, + ]; + } + case "session.task_complete": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(record.threadId) ?? RuntimeTaskId.makeUnsafe(record.threadId), + status: "completed", + ...(trimToUndefined(event.data.summary) ? { summary: event.data.summary } : {}), + }, + }, + ]; + case "assistant.turn_start": + return [ + { + ...base({ providerTurnId: toTurnId(event.data.turnId) }), + type: "turn.started", + payload: record.model ? { model: record.model } : {}, + }, + { + ...base({ providerTurnId: toTurnId(event.data.turnId) }), + type: "session.state.changed", + payload: { + state: "running", + reason: "assistant.turn_start", + }, + }, + ]; + case "assistant.reasoning": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.reasoning_delta": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.message": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.message_delta": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.turn_end": + return []; + case "assistant.usage": { + const usage = normalizeCopilotAssistantTokenUsage(event.data); + if (!usage) { + return []; + } + const completionRefs = completionTurnRefs(record); + const completionBase = + completionRefs.turnId || completionRefs.providerTurnId ? base(completionRefs) : base(); + return [ + { + ...completionBase, + type: "thread.token-usage.updated", + payload: { + usage, + }, + }, + ]; + } + case "abort": { + const abortedTurnRefs = completionTurnRefs(record); + const abortedBase = + abortedTurnRefs.turnId || abortedTurnRefs.providerTurnId + ? base(abortedTurnRefs) + : base(); + return [ + { + ...abortedBase, + type: "turn.aborted", + payload: { + reason: event.data.reason, + }, + }, + ]; + } + case "tool.execution_start": { + const startedItemType = itemTypeFromToolEvent(event); + const startedTitle = toolTitleFromItemType(startedItemType, event.data.toolName); + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.started", + payload: { + itemType: startedItemType, + status: "inProgress", + title: startedTitle, + ...(toolDetailFromEvent(event.data) + ? { detail: toolDetailFromEvent(event.data) } + : {}), + data: event.data, + }, + }, + ]; + } + case "tool.execution_progress": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.progressMessage, + }, + }, + ]; + case "tool.execution_partial_result": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.partialOutput, + }, + }, + ]; + case "tool.execution_complete": { + const completedDetail = toolResultDetailContent(event.data.result); + const diffChangedFiles = extractChangedFilesFromDiff(completedDetail); + const completedItemType = + diffChangedFiles.length > 0 + ? "file_change" + : (record.toolItemTypesByCallId.get(event.data.toolCallId) ?? + (event.data.result?.contents?.some((content) => content.type === "terminal") + ? "command_execution" + : "dynamic_tool_call")); + const completedTitle = + (diffChangedFiles.length > 0 ? "File change" : undefined) ?? + record.toolTitlesByCallId.get(event.data.toolCallId) ?? + toolTitleFromItemType(completedItemType); + const completedSummary = toolResultSummaryContent(event.data.result); + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.completed", + payload: { + itemType: completedItemType, + status: event.data.success ? "completed" : "failed", + title: completedTitle, + ...(completedDetail ? { detail: completedDetail } : {}), + data: + diffChangedFiles.length > 0 + ? { + ...event.data, + changes: diffChangedFiles.map((path) => ({ path })), + } + : event.data, + }, + }, + ...(completedSummary + ? [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.summary" as const, + payload: { + summary: completedSummary, + precedingToolUseIds: [event.data.toolCallId], + }, + }, + ] + : []), + ]; + } + case "skill.invoked": + return [ + { + ...base(), + type: "task.progress", + payload: { + taskId: + toRuntimeTaskId(event.data.name) ?? RuntimeTaskId.makeUnsafe(event.data.name), + description: `Invoked skill ${event.data.name}`, + }, + }, + ]; + case "subagent.started": + return [ + { + ...base(), + type: "task.started", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + description: trimToUndefined(event.data.agentDescription), + taskType: "subagent", + }, + }, + ]; + case "subagent.completed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "completed", + ...(trimToUndefined(event.data.agentDisplayName) + ? { summary: event.data.agentDisplayName } + : {}), + }, + }, + ]; + case "subagent.failed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? + RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "failed", + ...(trimToUndefined(event.data.error) ? { summary: event.data.error } : {}), + }, + }, + ]; + default: + return []; + } + }; + + const createInteractionHandlers = ( + threadId: ThreadId, + getCurrentTurnId: () => TurnId | undefined, + getRuntimeMode: () => ProviderSession["runtimeMode"], + pendingApprovalResolvers: Map, + pendingUserInputResolvers: Map, + ) => { + const onPermissionRequest = (request: PermissionRequest) => + getRuntimeMode() === "full-access" + ? Promise.resolve({ kind: "approved" }) + : new Promise((resolve) => { + const requestId = `copilot-approval-${randomUUID()}`; + const turnId = getCurrentTurnId(); + pendingApprovalResolvers.set(requestId, { + requestType: requestTypeFromPermissionRequest(request), + turnId, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "request.opened", + { + requestType: requestTypeFromPermissionRequest(request), + ...(requestDetailFromPermissionRequest(request) + ? { detail: requestDetailFromPermissionRequest(request) } + : {}), + args: request, + }, + { requestId, turnId }, + ), + ]); + }); + + const onUserInputRequest = (request: CopilotUserInputRequest) => + new Promise((resolve) => { + const requestId = `copilot-user-input-${randomUUID()}`; + const turnId = getCurrentTurnId(); + pendingUserInputResolvers.set(requestId, { + request, + turnId, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "user-input.requested", + { + questions: [ + { + id: USER_INPUT_QUESTION_ID, + header: USER_INPUT_QUESTION_HEADER, + question: request.question, + options: (request.choices ?? []).map((choice: string) => ({ + label: choice, + description: choice, + })), + }, + ], + }, + { requestId, turnId }, + ), + ]); + }); + + return { + onPermissionRequest, + onUserInputRequest, + }; + }; + + const validateSessionConfiguration = (input: { + readonly client: CopilotClientHandle; + readonly threadId: ThreadId; + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }) => + Effect.gen(function* () { + if (!input.model && !input.reasoningEffort) { + return; + } + + 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 () => { + if ( + input.model && + (input.model !== record.model || input.reasoningEffort !== record.reasoningEffort) + ) { + await record.session.rpc.model.switchTo({ + modelId: input.model, + ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), + }); + } + record.model = input.model; + record.reasoningEffort = input.reasoningEffort; + record.updatedAt = new Date().toISOString(); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.model.switchTo", + detail: toMessage(cause, "Failed to reconfigure GitHub Copilot session."), + cause, + }), + }); + + const handleSessionEvent = (record: ActiveCopilotSession, event: SessionEvent) => { + record.updatedAt = event.timestamp; + if (event.type === "assistant.turn_start") { + beginCopilotTurn(record, TurnId.makeUnsafe(event.data.turnId)); + } + if (event.type === "assistant.usage") { + recordTurnUsage(record, event.data); + } + if (event.type === "session.error") { + record.lastError = event.data.message; + } + if (event.type === "session.model_change") { + record.model = event.data.newModel; + } + if (event.type === "session.mode_changed") { + record.interactionMode = toInteractionMode(event.data.newMode); + } + if (event.type === "tool.execution_start") { + const itemType = itemTypeFromToolEvent(event); + record.toolItemTypesByCallId.set(event.data.toolCallId, itemType); + record.toolTitlesByCallId.set( + event.data.toolCallId, + toolTitleFromItemType(itemType, event.data.toolName), + ); + } + + void writeNativeEvent(record.threadId, event); + const runtimeEvents = mapSessionEvent(record, event); + if (runtimeEvents.length > 0) { + void emitRuntimeEvents(runtimeEvents); + } + if (event.type === "session.plan_changed" && event.data.operation !== "delete") { + void Effect.runPromise(emitLatestProposedPlan(record)).catch((cause) => { + void emitRuntimeEvents([ + makeSyntheticEvent( + record.threadId, + "runtime.warning", + { + message: "Failed to read GitHub Copilot plan.", + detail: toMessage(cause, "Failed to read GitHub Copilot plan."), + }, + { turnId: currentSyntheticTurnId(record) }, + ), + ]); + }); + } + if (event.type === "tool.execution_complete") { + record.toolItemTypesByCallId.delete(event.data.toolCallId); + record.toolTitlesByCallId.delete(event.data.toolCallId); + } + 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}'.`, + }); + } + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected modelSelection.provider '${PROVIDER}', received '${input.modelSelection.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + return { + provider: PROVIDER, + status: "ready", + runtimeMode: existing.runtimeMode, + ...(existing.cwd ? { cwd: existing.cwd } : {}), + ...(existing.model ? { model: existing.model } : {}), + threadId: input.threadId, + resumeCursor: existing.session.sessionId, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + ...(existing.lastError ? { lastError: existing.lastError } : {}), + } satisfies ProviderSession; + } + + const 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 modelSelection = getCopilotModelSelection(input); + const selectedModel = modelSelection?.model; + const reasoningEffort = getCopilotReasoningEffort(modelSelection); + let sessionRecord: ActiveCopilotSession | undefined; + const handlers = createInteractionHandlers( + input.threadId, + () => sessionRecord?.currentTurnId, + () => sessionRecord?.runtimeMode ?? input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + ); + + yield* validateSessionConfiguration({ + client, + threadId: input.threadId, + model: selectedModel, + reasoningEffort, + }); + + const session = yield* Effect.tryPromise({ + try: async () => { + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { + ...handlers, + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + } + const sessionConfig: Parameters[0] & { + sessionId?: string; + } = { + ...handlers, + sessionId: makeCopilotSessionId(input.threadId), + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }; + return client.createSession(sessionConfig); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot session."), + cause, + }), + }); + + const record = createSessionRecord({ + threadId: input.threadId, + client, + session, + runtimeMode: input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + cwd: input.cwd, + configDir, + model: selectedModel, + reasoningEffort, + }); + const unsubscribe = session.on((event) => { + handleSessionEvent(record, event); + }); + record.unsubscribe = unsubscribe; + sessionRecord = record; + sessions.set(input.threadId, record); + + yield* Queue.offerAll(runtimeEventQueue, [ + makeSyntheticEvent(input.threadId, "session.started", { + message: resumeSessionId + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: { sessionId: session.sessionId }, + }), + makeSyntheticEvent(input.threadId, "session.configured", { + config: { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }, + }), + makeSyntheticEvent(input.threadId, "thread.started", { + providerThreadId: session.sessionId, + }), + makeSyntheticEvent(input.threadId, "session.state.changed", { + state: "ready", + reason: "session.started", + }), + ]); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + threadId: input.threadId, + resumeCursor: session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession; + }); + + const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const record = yield* getSessionRecord(input.threadId); + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Expected modelSelection.provider '${PROVIDER}', received '${input.modelSelection.provider}'.`, + }); + } + + const modelSelection = getCopilotModelSelection(input); + const explicitReasoningEffort = getCopilotReasoningEffort(modelSelection); + const nextModel = modelSelection?.model ?? record.model; + const nextReasoningEffort = + explicitReasoningEffort !== undefined + ? explicitReasoningEffort + : modelSelection?.model && modelSelection.model !== record.model + ? undefined + : record.reasoningEffort; + const shouldReconfigure = + nextModel !== record.model || nextReasoningEffort !== record.reasoningEffort; + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: attachmentPath, + displayName: attachment.name, + }); + }); + + if (shouldReconfigure) { + yield* validateSessionConfiguration({ + client: record.client, + threadId: input.threadId, + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + yield* reconfigureSession(record, { + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + } + + const interactionMode = input.interactionMode ?? record.interactionMode ?? "default"; + yield* syncInteractionMode(record, interactionMode); + + const turnId = TurnId.makeUnsafe(`copilot-turn-${randomUUID()}`); + record.pendingTurnIds.push(turnId); + record.currentTurnId = turnId; + record.currentProviderTurnId = undefined; + + yield* Effect.tryPromise({ + try: () => + record.session.send({ + prompt: input.input ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: toMessage(cause, "Failed to send GitHub Copilot turn."), + cause, + }), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + record.pendingTurnIds = record.pendingTurnIds.filter( + (candidate) => candidate !== turnId, + ); + if (record.currentTurnId === turnId) { + record.currentTurnId = undefined; + } + }), + ), + ); + + record.updatedAt = new Date().toISOString(); + + return { + threadId: input.threadId, + turnId, + resumeCursor: record.session.sessionId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: () => record.session.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: toMessage(cause, "Failed to interrupt GitHub Copilot turn."), + cause, + }), + }); + }); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingApprovalResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.permission.respond", + detail: `Unknown pending GitHub Copilot approval request '${requestId}'.`, + }); + } + record.pendingApprovalResolvers.delete(requestId); + pending.resolve(approvalDecisionToPermissionResult(decision)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "request.resolved", + { + requestType: pending.requestType, + decision, + resolution: approvalDecisionToPermissionResult(decision), + }, + { requestId, turnId: pending.turnId }, + ), + ); + }); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingUserInputResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.userInput.respond", + detail: `Unknown pending GitHub Copilot user-input request '${requestId}'.`, + }); + } + record.pendingUserInputResolvers.delete(requestId); + pending.resolve(resolveUserInputAnswer(pending, answers)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "user-input.resolved", + { + answers, + }, + { requestId, turnId: pending.turnId }, + ), + ); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: async () => { + await stopRecord(record); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to stop GitHub Copilot session."), + cause, + }), + }); + }); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map((record) => + Object.assign( + { + provider: PROVIDER, + status: record.currentTurnId ? "running" : "ready", + runtimeMode: record.runtimeMode, + threadId: record.threadId, + resumeCursor: record.session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession, + record.cwd ? { cwd: record.cwd } : undefined, + record.model ? { model: record.model } : undefined, + record.currentTurnId ? { activeTurnId: record.currentTurnId } : undefined, + record.lastError ? { lastError: record.lastError } : undefined, + ), + ), + ); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + return yield* Effect.tryPromise({ + try: async () => { + const messages = await record.session.getMessages(); + return mapHistoryToTurns(threadId, messages); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.getMessages", + detail: toMessage(cause, "Failed to read GitHub Copilot thread history."), + cause, + }), + }); + }); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = (_threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: + "GitHub Copilot SDK does not expose a supported conversation rollback API for existing sessions.", + }), + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + await Promise.all(Array.from(sessions.values()).map((record) => stopRecord(record))); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: ThreadId.makeUnsafe("_all"), + detail: toMessage(cause, "Failed to stop GitHub Copilot sessions."), + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CopilotAdapterShape; + }); + +export const CopilotAdapterLive = Layer.effect(CopilotAdapter, makeCopilotAdapter()); + +export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { + return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..ed1a5bb65d 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CopilotAdapter, CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeCopilotAdapter: CopilotAdapterShape = { + provider: "copilot", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(CopilotAdapter, fakeCopilotAdapter), ), ), NodeServices.layer, @@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const copilot = yield* registry.getByProvider("copilot"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(copilot, fakeCopilotAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "copilot"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 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.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..93aeee5eb7 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -14,6 +14,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; +import { CopilotClient } from "@github/copilot-sdk"; import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -23,10 +24,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 ──────────────────────────────────────────────────── @@ -587,14 +599,96 @@ 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), + ]); + return { status, authStatus }; + } 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.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..e22b39953f --- /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 1cd8edac26..0a9d349c73 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -18,6 +18,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -73,9 +74,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/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 74d5b29df2..de2dcb0478 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -78,31 +78,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"); }); }); @@ -136,6 +150,8 @@ describe("getProviderStartOptions", () => { expect( getProviderStartOptions({ claudeBinaryPath: "/usr/local/bin/claude", + copilotCliPath: "/Applications/Copilot.app", + copilotConfigDir: "/Users/you/.config/copilot", codexBinaryPath: "", codexHomePath: "/Users/you/.codex", }), @@ -143,6 +159,10 @@ describe("getProviderStartOptions", () => { claudeAgent: { binaryPath: "/usr/local/bin/claude", }, + copilot: { + cliPath: "/Applications/Copilot.app", + configDir: "/Users/you/.config/copilot", + }, codex: { homePath: "/Users/you/.codex", }, @@ -153,6 +173,8 @@ describe("getProviderStartOptions", () => { expect( getProviderStartOptions({ claudeBinaryPath: "", + copilotCliPath: "", + copilotConfigDir: "", codexBinaryPath: "", codexHomePath: "", }), @@ -164,30 +186,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: ["copilot/default-model"], } as const; expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ "claude/default-opus", ]); + expect(getDefaultCustomModelsForProvider(defaults, "copilot")).toEqual([ + "copilot/default-model", + ]); }); it("patches custom models for codex", () => { @@ -202,10 +231,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"], }); }); @@ -218,12 +254,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: [" gpt-5.4 ", "copilot/custom-model", "copilot/custom-model"], }); expect( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index be9c376989..08425e4216 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -27,7 +27,7 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; -type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels" | "customCopilotModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -41,6 +41,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 = @@ -58,6 +59,8 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: 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(() => "")), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), @@ -73,6 +76,7 @@ export const AppSettingsSchema = Schema.Struct({ 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; @@ -102,6 +106,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record, + settings: Pick< + AppSettings, + "claudeBinaryPath" | "copilotCliPath" | "copilotConfigDir" | "codexBinaryPath" | "codexHomePath" + >, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath @@ -258,6 +277,14 @@ export function getProviderStartOptions( }, } : {}), + ...(settings.copilotCliPath || settings.copilotConfigDir + ? { + copilot: { + ...(settings.copilotCliPath ? { cliPath: settings.copilotCliPath } : {}), + ...(settings.copilotConfigDir ? { configDir: settings.copilotConfigDir } : {}), + }, + } + : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbc887bf62..7953929d0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -195,6 +195,41 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +function buildModelSelection( + provider: ProviderKind, + model: ModelSlug, + options?: ModelSelection["options"], +): ModelSelection { + switch (provider) { + case "codex": + return { + provider, + model, + ...(options + ? { options: options as Extract["options"] } + : {}), + }; + case "claudeAgent": + return { + provider, + model, + ...(options + ? { + options: options as Extract["options"], + } + : {}), + }; + case "copilot": + return { + provider, + model, + ...(options + ? { options: options as Extract["options"] } + : {}), + }; + } +} + function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; @@ -632,11 +667,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const selectedModelSelection = useMemo( - () => ({ - provider: selectedProvider, - model: selectedModel, - ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), - }), + () => buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); @@ -2562,14 +2593,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncateTitle(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || + const threadCreateModelSelection = buildModelSelection( + selectedProvider, + selectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; + selectedModelSelection.options, + ); if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -3112,10 +3142,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - const nextModelSelection: ModelSelection = { - provider, - model: resolvedModel, - }; + const nextModelSelection = buildModelSelection(provider, resolvedModel); setComposerDraftModelSelection(activeThread.id, nextModelSelection); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1694b374c8..6cae024f6a 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: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + ], 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("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..4b2d733ef1 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, }; @@ -45,7 +46,13 @@ function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; + if (provider === "claudeAgent") { + return "text-[#d97757]"; + } + if (provider === "copilot") { + return "text-foreground"; + } + return fallbackClassName; } export const ProviderModelPicker = memo(function ProviderModelPicker(props: { diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 811ad5bb35..09e8a8d69c 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -37,7 +37,7 @@ function ClaudeTraitsPickerHarness(props: { selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [] }, + customModelsByProvider: { codex: [], claudeAgent: [], copilot: [] }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e43c094283..0bf9b14616 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,5 +1,6 @@ import { type ClaudeModelOptions, + type CopilotModelOptions, type CodexModelOptions, type ProviderKind, type ProviderModelOptions, @@ -35,7 +36,7 @@ function getRawEffort( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, ): string | null { - if (provider === "codex") { + if (provider === "codex" || provider === "copilot") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); @@ -49,6 +50,12 @@ function buildNextOptions( if (provider === "codex") { return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; } + if (provider === "copilot") { + return { + ...(modelOptions as CopilotModelOptions | undefined), + ...patch, + } as CopilotModelOptions; + } return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; } @@ -142,7 +149,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange(nextPrompt); return; } - const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + const effortKey = provider === "claudeAgent" ? "effort" : "reasoningEffort"; setProviderModelOptions( threadId, provider, diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 088a2a47be..cd82f98fef 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -8,6 +8,7 @@ import { getModelCapabilities, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, + normalizeCopilotModelOptions, normalizeCodexModelOptions, trimOrNull, getDefaultEffort, @@ -81,8 +82,10 @@ function getProviderStateFromCapabilities( // Normalize options for dispatch const normalizedOptions = provider === "codex" - ? normalizeCodexModelOptions(model, providerOptions) - : normalizeClaudeModelOptions(model, providerOptions); + ? normalizeCodexModelOptions(model, modelOptions?.codex) + : provider === "copilot" + ? normalizeCopilotModelOptions(model, modelOptions?.copilot) + : normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -147,6 +150,29 @@ const composerProviderRegistry: Record = { /> ), }, + copilot: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + + ), + renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fb9c0d5150..5a09ce18ab 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -408,7 +408,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( @@ -425,6 +425,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" || @@ -485,15 +489,57 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const copilotReasoningEffort: CodexReasoningEffort | undefined = + copilotCandidate?.reasoningEffort === "low" || + copilotCandidate?.reasoningEffort === "medium" || + copilotCandidate?.reasoningEffort === "high" || + copilotCandidate?.reasoningEffort === "xhigh" + ? copilotCandidate.reasoningEffort + : undefined; + const copilot = + copilotReasoningEffort !== undefined + ? { + reasoningEffort: copilotReasoningEffort, + } + : undefined; + + if (!codex && !claude && !copilot) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(copilot ? { copilot } : {}), }; } +function buildModelSelectionForProvider( + provider: ProviderKind, + model: ModelSlug, + options?: ProviderModelOptions[ProviderKind], +): ModelSelection { + switch (provider) { + case "codex": + return { + provider, + model, + ...(options ? { options: options as ProviderModelOptions["codex"] } : {}), + }; + case "claudeAgent": + return { + provider, + model, + ...(options ? { options: options as ProviderModelOptions["claudeAgent"] } : {}), + }; + case "copilot": + return { + provider, + model, + ...(options ? { options: options as ProviderModelOptions["copilot"] } : {}), + }; + } +} + function normalizeModelSelection( value: unknown, legacy?: { @@ -521,12 +567,13 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; - return { - provider, - model, - ...(options ? { options } : {}), - }; + const options = + provider === "codex" + ? modelOptions?.codex + : provider === "claudeAgent" + ? modelOptions?.claudeAgent + : modelOptions?.copilot; + return buildModelSelectionForProvider(provider, model, options); } // ── Legacy sync helpers (used only during migration from v2 storage) ── @@ -539,11 +586,7 @@ function legacySyncModelSelectionOptions( return null; } const options = modelOptions?.[modelSelection.provider]; - return { - provider: modelSelection.provider, - model: modelSelection.model, - ...(options ? { options } : {}), - }; + return buildModelSelectionForProvider(modelSelection.provider, modelSelection.model, options); } function legacyMergeModelSelectionIntoProviderModelOptions( @@ -587,17 +630,14 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "copilot"] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = { + result[provider] = buildModelSelectionForProvider( provider, - model: - modelSelection?.provider === provider - ? modelSelection.model - : getDefaultModel(provider), + modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider), options, - }; + ); } } } @@ -1629,9 +1669,11 @@ export const useComposerDraftStore = create()( } else { // No options in selection → preserve existing options, update provider+model nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), + ...buildModelSelectionForProvider( + normalized.provider, + normalized.model, + current?.options, + ), }; } } @@ -1668,17 +1710,17 @@ export const useComposerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "copilot"] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { - nextMap[provider] = { + nextMap[provider] = buildModelSelectionForProvider( provider, - model: current?.model ?? getDefaultModel(provider), - options: opts, - }; + current?.model ?? getDefaultModel(provider), + opts, + ); } else if (current?.options) { // Remove options but keep the selection const { options: _, ...rest } = current; @@ -1724,11 +1766,11 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), - options: providerOpts, - }; + nextMap[normalizedProvider] = buildModelSelectionForProvider( + normalizedProvider, + currentForProvider?.model ?? getDefaultModel(normalizedProvider), + providerOpts, + ); } else if (currentForProvider?.options) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; @@ -1742,16 +1784,16 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: getDefaultModel(normalizedProvider), - } as ModelSelection); + buildModelSelectionForProvider( + normalizedProvider, + getDefaultModel(normalizedProvider), + ); if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, - provider: normalizedProvider, - options: providerOpts, - }; + nextStickyMap[normalizedProvider] = buildModelSelectionForProvider( + normalizedProvider, + stickyBase.model, + providerOpts, + ); } else if (stickyBase.options) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 05fd640d0f..eff3b7ecc6 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -58,14 +58,14 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +type InstallBinarySettingsKey = "claudeBinaryPath" | "copilotCliPath" | "codexBinaryPath"; type InstallProviderSettings = { provider: ProviderKind; title: string; binaryPathKey: InstallBinarySettingsKey; binaryPlaceholder: string; binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; + homePathKey?: "codexHomePath" | "copilotConfigDir"; homePlaceholder?: string; homeDescription?: ReactNode; }; @@ -96,6 +96,16 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ ), }, + { + provider: "copilot", + title: "GitHub Copilot", + binaryPathKey: "copilotCliPath", + binaryPlaceholder: "Copilot CLI path", + binaryDescription: <>Leave blank to use the bundled GitHub Copilot CLI when available., + homePathKey: "copilotConfigDir", + homePlaceholder: "Copilot config directory", + homeDescription: "Optional custom GitHub Copilot config directory.", + }, ]; function SettingsSection({ title, children }: { title: string; children: ReactNode }) { @@ -194,6 +204,7 @@ function SettingsRouteView() { const [openInstallProviders, setOpenInstallProviders] = useState>({ codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), claudeAgent: Boolean(settings.claudeBinaryPath), + copilot: Boolean(settings.copilotCliPath || settings.copilotConfigDir), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -202,6 +213,7 @@ function SettingsRouteView() { >({ codex: "", claudeAgent: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -211,6 +223,8 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const claudeBinaryPath = settings.claudeBinaryPath; + const copilotCliPath = settings.copilotCliPath; + const copilotConfigDir = settings.copilotConfigDir; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -233,7 +247,10 @@ function SettingsRouteView() { )!; const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; + const totalCustomModels = + settings.customCodexModels.length + + settings.customClaudeModels.length + + settings.customCopilotModels.length; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ key: `${providerSettings.provider}:${slug}`, @@ -247,6 +264,8 @@ function SettingsRouteView() { : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.copilotCliPath !== defaults.copilotCliPath || + settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; const changedSettingLabels = [ @@ -261,7 +280,9 @@ function SettingsRouteView() { ? ["Delete confirmation"] : []), ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 + ...(settings.customCodexModels.length > 0 || + settings.customClaudeModels.length > 0 || + settings.customCopilotModels.length > 0 ? ["Custom models"] : []), ...(isInstallSettingsDirty ? ["Provider installs"] : []), @@ -370,11 +391,13 @@ function SettingsRouteView() { setOpenInstallProviders({ codex: false, claudeAgent: false, + copilot: false, }); setSelectedCustomModelProvider("codex"); setCustomModelInputByProvider({ codex: "", claudeAgent: "", + copilot: "", }); setCustomModelErrorByProvider({}); } @@ -682,6 +705,7 @@ function SettingsRouteView() { updateSettings({ customCodexModels: defaults.customCodexModels, customClaudeModels: defaults.customClaudeModels, + customCopilotModels: defaults.customCopilotModels, }); setCustomModelErrorByProvider({}); setShowAllCustomModels(false); @@ -695,10 +719,14 @@ function SettingsRouteView() { - updateSettings({ - codexHomePath: event.target.value, - }) + value={ + providerSettings.homePathKey === "codexHomePath" + ? codexHomePath + : copilotConfigDir } + onChange={(event) => { + if (providerSettings.homePathKey === "codexHomePath") { + updateSettings({ codexHomePath: event.target.value }); + return; + } + updateSettings({ copilotConfigDir: event.target.value }); + }} placeholder={providerSettings.homePlaceholder} spellCheck={false} /> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72b..8422144ef1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1129,12 +1129,14 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { + it("advertises Copilot and Claude while keeping Cursor as a placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); + const copilot = PROVIDER_OPTIONS.find((option) => option.value === "copilot"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "copilot", label: "Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ @@ -1142,6 +1144,11 @@ describe("PROVIDER_OPTIONS", () => { label: "Claude", available: true, }); + expect(copilot).toEqual({ + value: "copilot", + label: "Copilot", + available: true, + }); expect(cursor).toEqual({ value: "cursor", label: "Cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d6313..da87a19afa 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "copilot", label: "Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4590b2886d..b4a26a3cb6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -193,7 +193,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "copilot") { return providerName; } return "codex"; diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf25..7aa2e795b9 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,15 +1,37 @@ import { ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +function createLocalStorageMock(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(key) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, value); + }, + }; +} + describe("terminalStateStore actions", () => { beforeEach(() => { - if (typeof localStorage !== "undefined") { - localStorage.clear(); - } + vi.stubGlobal("localStorage", createLocalStorageMock()); useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index b2cea6d560..94b17980f4 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -8,6 +8,7 @@ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -27,6 +28,27 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +function createTerminalStateStorage(): StateStorage { + if (typeof localStorage === "undefined") { + return createMemoryStorage(); + } + + const storage = localStorage as Partial; + if ( + typeof storage.getItem === "function" && + typeof storage.setItem === "function" && + typeof storage.removeItem === "function" + ) { + return { + getItem: (name) => storage.getItem!(name), + setItem: (name, value) => storage.setItem!(name, value), + removeItem: (name) => storage.removeItem!(name), + }; + } + + return createMemoryStorage(); +} + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; @@ -542,7 +564,7 @@ export const useTerminalStateStore = create()( { name: TERMINAL_STATE_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(createTerminalStateStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }), diff --git a/bun.lock b/bun.lock index 857d3a83c7..05024f94d3 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.10", + "@github/copilot-sdk": "0.2.0", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -361,6 +363,22 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], + "@github/copilot": ["@github/copilot@1.0.10", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.10", "@github/copilot-darwin-x64": "1.0.10", "@github/copilot-linux-arm64": "1.0.10", "@github/copilot-linux-x64": "1.0.10", "@github/copilot-win32-arm64": "1.0.10", "@github/copilot-win32-x64": "1.0.10" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-RpHYMXYpyAgQLYQ3MB8ubV8zMn/zDatwaNmdxcC8ws7jqM+Ojy7Dz4KFKzyT0rCrWoUCAEBXsXoPbP0LY0FgLw=="], + + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.10", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-MNlzwkTQ9iUgHQ+2Z25D0KgYZDEl4riEa1Z4/UCNpHXmmBiIY8xVRbXZTNMB69cnagjQ5Z8D2QM2BjI0kqeFPg=="], + + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.10", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-zAQBCbEue/n4xHBzE9T03iuupVXvLtu24MDMeXXtIC0d4O+/WV6j1zVJrp9Snwr0MBWYH+wUrV74peDDdd1VOQ=="], + + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.10", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-7mJ3uLe7ITyRi2feM1rMLQ5d0bmUGTUwV1ZxKZwSzWCYmuMn05pg4fhIUdxZZZMkLbOl3kG/1J7BxMCTdS2w7A=="], + + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.10", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-66NPaxroRScNCs6TZGX3h1RSKtzew0tcHBkj4J1AHkgYLjNHMdjjBwokGtKeMxzYOCAMBbmJkUDdNGkqsKIKUA=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@0.2.0", "", { "dependencies": { "@github/copilot": "^1.0.10", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-fCEpD9W9xqcaCAJmatyNQ1PkET9P9liK2P4Vk0raDFoMXcvpIdqewa5JQeKtWCBUsN/HCz7ExkkFP8peQuo+DA=="], + + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.10", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-WC5M+M75sxLn4lvZ1wPA1Lrs/vXFisPXJPCKbKOMKqzwMLX/IbuybTV4dZDIyGEN591YmOdRIylUF0tVwO8Zmw=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.10", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-tUfIwyamd0zpm9DVTtbjIWF6j3zrA5A5IkkiuRgsy0HRJPQpeAV7ZYaHEZteHrynaULpl1Gn/Dq0IB4hYc4QtQ=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], @@ -1823,7 +1841,7 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1995,6 +2013,8 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9997809d2b..d3fe7d4a7b 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; @@ -185,6 +191,103 @@ export const MODEL_OPTIONS_BY_PROVIDER = { }, }, ], + copilot: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-opus-4.6", + name: "Claude Opus 4.6", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-opus-4.6-fast", + name: "Claude Opus 4.6 (Fast Mode)", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gemini-3.0", + name: "Gemini 3.0 Pro", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -194,6 +297,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + copilot: "gpt-5.4", }; // Backward compatibility for existing Codex-only call sites. @@ -223,6 +327,20 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + copilot: "Copilot", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 333d5ca1eb..6d0e63b639 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { ClaudeModelOptions, CopilotModelOptions, CodexModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "copilot"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -58,7 +58,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const CopilotModelSelection = Schema.Struct({ + provider: Schema.Literal("copilot"), + model: TrimmedNonEmptyString, + options: Schema.optional(CopilotModelOptions), +}); +export type CopilotModelSelection = typeof CopilotModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + CopilotModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const CodexProviderStartOptions = Schema.Struct({ @@ -72,9 +83,15 @@ export const ClaudeProviderStartOptions = Schema.Struct({ maxThinkingTokens: Schema.optional(NonNegativeInt), }); +export const CopilotProviderStartOptions = Schema.Struct({ + cliPath: Schema.optional(TrimmedNonEmptyString), + configDir: Schema.optional(TrimmedNonEmptyString), +}); + 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 81231d88f6..166b052fbf 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "copilot.sdk.session-event", + "copilot.sdk.synthetic", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 53ebc856fd..2295fcc72e 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -4,6 +4,7 @@ import { MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeModelOptions, + type CopilotModelOptions, type ClaudeCodeEffort, type CodexModelOptions, type ModelCapabilities, @@ -15,6 +16,7 @@ import { 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)), }; export interface SelectableModelOption { @@ -186,6 +188,22 @@ export function normalizeClaudeModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeCopilotModelOptions( + model: string | null | undefined, + modelOptions: CopilotModelOptions | null | undefined, +): CopilotModelOptions | undefined { + const caps = getModelCapabilities("copilot", model); + const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort | null; + const resolvedReasoningEffort = trimOrNull(modelOptions?.reasoningEffort); + const reasoningEffort = + resolvedReasoningEffort && + hasEffortLevel(caps, resolvedReasoningEffort) && + resolvedReasoningEffort !== defaultReasoningEffort + ? resolvedReasoningEffort + : undefined; + return reasoningEffort ? { reasoningEffort } : undefined; +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, From 477ff045cda32da4bd8048d0b59f0a312c2fe88f Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 25 Mar 2026 10:45:10 +0100 Subject: [PATCH 2/5] test: isolate terminal state store localStorage mock --- apps/web/src/terminalStateStore.test.ts | 9 ++++++--- apps/web/src/terminalStateStore.ts | 24 +----------------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 7aa2e795b9..ec1809ebf7 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,10 +1,11 @@ import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; - const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +let selectThreadTerminalState: typeof import("./terminalStateStore").selectThreadTerminalState; +let useTerminalStateStore: typeof import("./terminalStateStore").useTerminalStateStore; + function createLocalStorageMock(): Storage { const values = new Map(); return { @@ -30,8 +31,10 @@ function createLocalStorageMock(): Storage { } describe("terminalStateStore actions", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.stubGlobal("localStorage", createLocalStorageMock()); + ({ selectThreadTerminalState, useTerminalStateStore } = await import("./terminalStateStore")); useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 94b17980f4..b2cea6d560 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -8,7 +8,6 @@ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import { createMemoryStorage, type StateStorage } from "./lib/storage"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -28,27 +27,6 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; -function createTerminalStateStorage(): StateStorage { - if (typeof localStorage === "undefined") { - return createMemoryStorage(); - } - - const storage = localStorage as Partial; - if ( - typeof storage.getItem === "function" && - typeof storage.setItem === "function" && - typeof storage.removeItem === "function" - ) { - return { - getItem: (name) => storage.getItem!(name), - setItem: (name, value) => storage.setItem!(name, value), - removeItem: (name) => storage.removeItem!(name), - }; - } - - return createMemoryStorage(); -} - function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; @@ -564,7 +542,7 @@ export const useTerminalStateStore = create()( { name: TERMINAL_STATE_STORAGE_KEY, version: 1, - storage: createJSONStorage(createTerminalStateStorage), + storage: createJSONStorage(() => localStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }), From a13bd03daff97599959688ab4adb8af98e10b43e Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 25 Mar 2026 11:22:13 +0100 Subject: [PATCH 3/5] Fix Copilot loader path fallback resolution --- apps/server/src/provider/Layers/copilotCliPath.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts index e22b39953f..dce3dc77b4 100644 --- a/apps/server/src/provider/Layers/copilotCliPath.ts +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -156,7 +156,7 @@ export function resolveBundledCopilotCliPathFrom(input: { join(githubScopeDir, packageName, binaryName), ); const sdkSiblingLoaderPath = join(githubScopeDir, "copilot", COPILOT_NPM_LOADER); - for (const candidate of dedupePaths([...sdkSiblingBinaryCandidates, ...sdkSiblingLoaderPath])) { + for (const candidate of dedupePaths([...sdkSiblingBinaryCandidates, sdkSiblingLoaderPath])) { if (exists(candidate)) { return candidate; } From cb47fb375d8ad11fe9aa147a42ade3177dc78ec7 Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 25 Mar 2026 11:29:31 +0100 Subject: [PATCH 4/5] Isolate Copilot adapter test sessions --- .../provider/Layers/CopilotAdapter.test.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 7a81fcc9bb..5335e2725f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -4,6 +4,7 @@ import { ThreadId } from "@t3tools/contracts"; import { type SessionEvent } from "@github/copilot-sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterAll, it, vi } from "@effect/vitest"; +import { beforeEach } from "vitest"; import { Effect, Fiber, Layer, Stream } from "effect"; @@ -432,8 +433,14 @@ reasoningLayer("CopilotAdapterLive reasoning", (it) => { ); }); -const toolEventSession = new FakeCopilotSession("copilot-session-tool-events"); -const toolEventClient = new FakeCopilotClient(toolEventSession); +let toolEventSession: FakeCopilotSession; +let toolEventClient: FakeCopilotClient; + +beforeEach(() => { + toolEventSession = new FakeCopilotSession("copilot-session-tool-events"); + toolEventClient = new FakeCopilotClient(toolEventSession); +}); + const toolEventLayer = it.layer( makeCopilotAdapterLive({ clientFactory: () => toolEventClient, @@ -537,8 +544,14 @@ toolEventLayer("CopilotAdapterLive tool event mapping", (it) => { ); }); -const toolTitleSession = new FakeCopilotSession("copilot-session-tool-titles"); -const toolTitleClient = new FakeCopilotClient(toolTitleSession); +let toolTitleSession: FakeCopilotSession; +let toolTitleClient: FakeCopilotClient; + +beforeEach(() => { + toolTitleSession = new FakeCopilotSession("copilot-session-tool-titles"); + toolTitleClient = new FakeCopilotClient(toolTitleSession); +}); + const toolTitleLayer = it.layer( makeCopilotAdapterLive({ clientFactory: () => toolTitleClient, @@ -700,7 +713,7 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { attachments: [], }); - const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( Stream.runCollect, Effect.forkChild, ); @@ -766,7 +779,7 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { attachments: [], }); - const eventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( Stream.runCollect, Effect.forkChild, ); From 5c6b51edfdad98c1d70006fadbf97ef721bb5d6e Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 25 Mar 2026 11:58:26 +0100 Subject: [PATCH 5/5] Fix Copilot tool detail and timeline icon handling --- .../provider/Layers/CopilotAdapter.test.ts | 119 ++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 102 ++++++++- .../components/chat/MessagesTimeline.test.tsx | 213 +++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 36 ++- 4 files changed, 348 insertions(+), 122 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5335e2725f..e3ed15999c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -154,6 +154,17 @@ function makeCopilotModelSelection( }; } +function diffDetailedContent(path: string) { + return [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); +} + const modeSession = new FakeCopilotSession("copilot-session-mode"); const modeClient = new FakeCopilotClient(modeSession); const modeLayer = it.layer( @@ -664,32 +675,33 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { } satisfies SessionEvent); const events = Array.from(yield* Fiber.join(eventsFiber)).filter( - (event) => event.type === "item.started" || event.type === "item.completed", + (event) => event.type === "item.completed", ); assert.deepStrictEqual( events.map((event) => - "payload" in event && event.payload && typeof event.payload === "object" + event.payload && typeof event.payload === "object" ? { - type: event.type, - itemType: "itemType" in event.payload ? event.payload.itemType : undefined, - title: "title" in event.payload ? event.payload.title : undefined, + itemType: event.payload.itemType, + title: event.payload.title, + detail: event.payload.detail, } - : { type: event.type, itemType: undefined, title: undefined }, + : null, ), [ - { type: "item.started", itemType: "dynamic_tool_call", title: "Read file" }, - { type: "item.completed", itemType: "dynamic_tool_call", title: "Read file" }, - { type: "item.started", itemType: "dynamic_tool_call", title: "Grep" }, - { type: "item.completed", itemType: "dynamic_tool_call", title: "Grep" }, { - type: "item.started", itemType: "dynamic_tool_call", - title: "List directory", + title: "Read file", + detail: "README.md", + }, + { + itemType: "dynamic_tool_call", + title: "Grep", + detail: "Copilot", }, { - type: "item.completed", itemType: "dynamic_tool_call", title: "List directory", + detail: ".", }, ], ); @@ -762,7 +774,7 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { }), ); - it.effect("maps diff-like Copilot detailedContent to a file change completion", () => + it.effect("keeps diff-like detailedContent from read tools as a read-style tool call", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const session = yield* adapter.startSession({ @@ -792,6 +804,9 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { data: { toolCallId: "tool-call-diff", toolName: "view", + arguments: { + path: "apps/web/src/foo.ts", + }, }, } satisfies SessionEvent); toolTitleSession.emit({ @@ -804,14 +819,73 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { success: true, result: { content: "Updated file", - detailedContent: [ - "diff --git a/apps/web/src/foo.ts b/apps/web/src/foo.ts", - "--- a/apps/web/src/foo.ts", - "+++ b/apps/web/src/foo.ts", - "@@ -1 +1 @@", - "-old", - "+new", - ].join("\n"), + detailedContent: diffDetailedContent("apps/web/src/foo.ts"), + }, + }, + } satisfies SessionEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedEvent = events.find((event) => event.type === "item.completed"); + + assert.equal(completedEvent?.type, "item.completed"); + if (completedEvent?.type === "item.completed") { + assert.equal(completedEvent.payload.itemType, "dynamic_tool_call"); + assert.equal(completedEvent.payload.title, "Read file"); + assert.equal(completedEvent.payload.detail, "apps/web/src/foo.ts"); + assert.deepStrictEqual( + (completedEvent.payload.data as { changes?: Array<{ path: string }> }).changes, + undefined, + ); + } + }), + ); + + it.effect("maps diff-like detailedContent from edit tools to a file change completion", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-tool-edit-diff-file-change"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Apply an edit", + attachments: [], + }); + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + toolTitleSession.emit({ + id: "evt-tool-start-edit-diff", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-call-edit-diff", + toolName: "edit", + arguments: { + path: "apps/web/src/foo.ts", + }, + }, + } satisfies SessionEvent); + toolTitleSession.emit({ + id: "evt-tool-complete-edit-diff", + timestamp: new Date().toISOString(), + parentId: "evt-tool-start-edit-diff", + type: "tool.execution_complete", + data: { + toolCallId: "tool-call-edit-diff", + success: true, + result: { + content: "Updated file", + detailedContent: diffDetailedContent("apps/web/src/foo.ts"), }, }, } satisfies SessionEvent); @@ -823,6 +897,7 @@ toolTitleLayer("CopilotAdapterLive tool titles", (it) => { if (completedEvent?.type === "item.completed") { assert.equal(completedEvent.payload.itemType, "file_change"); assert.equal(completedEvent.payload.title, "File change"); + assert.equal(completedEvent.payload.detail, "apps/web/src/foo.ts"); assert.deepStrictEqual( (completedEvent.payload.data as { changes?: Array<{ path: string }> }).changes, [{ path: "apps/web/src/foo.ts" }], diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 2959abdeec..8f59136186 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -108,6 +108,7 @@ interface ActiveCopilotSession extends CopilotTurnTrackingState { lastError: string | undefined; toolItemTypesByCallId: Map; toolTitlesByCallId: Map; + toolDetailsByCallId: Map; pendingApprovalResolvers: Map; pendingUserInputResolvers: Map; unsubscribe: () => void; @@ -518,14 +519,58 @@ function toolTitleFromItemType(itemType: ToolLifecycleItemType, toolName?: strin } } +function summarizeArgumentList(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const values = value + .map((entry) => normalizeString(entry)) + .filter((entry): entry is string => entry !== undefined); + const [firstValue] = values; + if (!firstValue) { + return undefined; + } + return values.length === 1 ? firstValue : `${firstValue} +${values.length - 1} more`; +} + +function toolArgumentDetail(argumentsValue: { readonly [k: string]: unknown } | undefined) { + if (!argumentsValue) { + return undefined; + } + + for (const key of ["path", "directory", "dir", "pattern", "glob", "query", "url", "command"]) { + const value = normalizeString(argumentsValue[key]); + if (value) { + return value; + } + } + + for (const key of ["paths", "files", "globs", "patterns"]) { + const value = summarizeArgumentList(argumentsValue[key]); + if (value) { + return value; + } + } + + return undefined; +} + function toolDetailFromEvent(data: { readonly toolName?: string; readonly mcpToolName?: string; readonly mcpServerName?: string; + readonly arguments?: { + readonly [k: string]: unknown; + }; }) { - return trimToUndefined( - [data.mcpServerName, data.mcpToolName ?? data.toolName].filter(Boolean).join(" / "), - ); + const argumentDetail = toolArgumentDetail(data.arguments); + if (argumentDetail) { + return argumentDetail; + } + if (data.mcpToolName || data.mcpServerName) { + return trimToUndefined([data.mcpServerName, data.mcpToolName ?? data.toolName].join(" / ")); + } + return undefined; } function toolResultSummaryContent( @@ -545,6 +590,29 @@ function toolResultDetailContent( return trimToUndefined(result?.detailedContent) ?? trimToUndefined(result?.content); } +function completedToolDetail(input: { + readonly itemType: ToolLifecycleItemType; + readonly success: boolean; + readonly startedDetail: string | undefined; + readonly resultDetail: string | undefined; +}): string | undefined { + if (!input.success) { + return input.resultDetail ?? input.startedDetail; + } + + if ( + input.startedDetail && + (input.itemType === "dynamic_tool_call" || + input.itemType === "file_change" || + input.itemType === "web_search" || + input.itemType === "image_view") + ) { + return input.startedDetail; + } + + return input.resultDetail ?? input.startedDetail; +} + function looksLikeDiffDetail(detail: string | undefined): boolean { if (!detail) { return false; @@ -761,6 +829,7 @@ function createSessionRecord(input: { pendingTurnUsage: undefined, toolItemTypesByCallId: new Map(), toolTitlesByCallId: new Map(), + toolDetailsByCallId: new Map(), pendingApprovalResolvers: input.pendingApprovalResolvers, pendingUserInputResolvers: input.pendingUserInputResolvers, unsubscribe: () => undefined, @@ -1206,19 +1275,23 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, ]; case "tool.execution_complete": { - const completedDetail = toolResultDetailContent(event.data.result); - const diffChangedFiles = extractChangedFilesFromDiff(completedDetail); + const resultDetail = toolResultDetailContent(event.data.result); const completedItemType = - diffChangedFiles.length > 0 - ? "file_change" - : (record.toolItemTypesByCallId.get(event.data.toolCallId) ?? - (event.data.result?.contents?.some((content) => content.type === "terminal") - ? "command_execution" - : "dynamic_tool_call")); + record.toolItemTypesByCallId.get(event.data.toolCallId) ?? + (event.data.result?.contents?.some((content) => content.type === "terminal") + ? "command_execution" + : "dynamic_tool_call"); + const diffChangedFiles = + completedItemType === "file_change" ? extractChangedFilesFromDiff(resultDetail) : []; const completedTitle = - (diffChangedFiles.length > 0 ? "File change" : undefined) ?? record.toolTitlesByCallId.get(event.data.toolCallId) ?? toolTitleFromItemType(completedItemType); + const completedDetail = completedToolDetail({ + itemType: completedItemType, + success: event.data.success, + startedDetail: record.toolDetailsByCallId.get(event.data.toolCallId), + resultDetail, + }); const completedSummary = toolResultSummaryContent(event.data.result); return [ { @@ -1514,6 +1587,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => event.data.toolCallId, toolTitleFromItemType(itemType, event.data.toolName), ); + const toolDetail = toolDetailFromEvent(event.data); + if (toolDetail) { + record.toolDetailsByCallId.set(event.data.toolCallId, toolDetail); + } } void writeNativeEvent(record.threadId, event); @@ -1539,6 +1616,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => if (event.type === "tool.execution_complete") { record.toolItemTypesByCallId.delete(event.data.toolCallId); record.toolTitlesByCallId.delete(event.data.toolCallId); + record.toolDetailsByCallId.delete(event.data.toolCallId); } if (event.type === "assistant.turn_end") { markTurnAwaitingCompletion(record); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..e9f8969bdf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,7 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { TimelineEntry } from "../../session-logic"; function matchMedia() { return { @@ -42,55 +43,59 @@ beforeAll(() => { }); }); +async function renderTimelineMarkup(timelineEntries: TimelineEntry[]) { + const { MessagesTimeline } = await import("./MessagesTimeline"); + return renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimelineMarkup([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]); expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); @@ -98,46 +103,84 @@ describe("MessagesTimeline", () => { }); it("renders context compaction entries in the normal work log", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimelineMarkup([ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ]); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("uses the activity label as the icon fallback when toolTitle is absent", async () => { + const markup = await renderTimelineMarkup([ + { + id: "entry-read-no-title", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-read-no-title", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Read file", + itemType: "dynamic_tool_call", + detail: + "/Users/zortos/t3code/apps/server/src/orchestration/Services/OrchestrationEngine.ts", + tone: "tool", + }, + }, + { + id: "entry-glob-no-title", + kind: "work", + createdAt: "2026-03-17T19:12:29.000Z", + entry: { + id: "work-glob-no-title", + createdAt: "2026-03-17T19:12:29.000Z", + label: "Glob", + itemType: "dynamic_tool_call", + detail: "/Users/zortos/t3code/apps/server/src", + tone: "tool", + }, + }, + ]); + + expect(markup).toContain("Read file"); + expect(markup).toContain("Glob"); + expect(markup).toContain("lucide-eye"); + expect(markup).toContain("lucide-search"); + expect(markup).not.toContain("lucide-hammer"); + }); + + it("prefers changed file paths over raw diff text in file change rows", async () => { + const markup = await renderTimelineMarkup([ + { + id: "entry-file-change", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-file-change", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Tool call", + toolTitle: "File change", + itemType: "file_change", + detail: "diff --git a/TESTING.md b/TESTING.md\n--- a/dev/null\n+++ b/TESTING.md", + changedFiles: ["/Users/zortos/t3code/TESTING.md"], + tone: "tool", + }, + }, + ]); + + expect(markup).toContain("File change"); + expect(markup).toContain("/Users/zortos/t3code/TESTING.md"); + expect(markup).not.toContain("diff --git"); + expect(markup).toContain("lucide-square-pen"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..d3ae278c38 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -27,6 +27,7 @@ import { GlobeIcon, HammerIcon, type LucideIcon, + SearchIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -801,18 +802,31 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { } function workEntryPreview( - workEntry: Pick, + workEntry: Pick< + TimelineWorkEntry, + "detail" | "command" | "changedFiles" | "itemType" | "toolTitle" | "label" + >, ) { if (workEntry.command) return workEntry.command; - if (workEntry.detail) return workEntry.detail; - if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; + const workEntryTitle = workEntryTitleKey(workEntry); + if (firstPath && (workEntry.itemType === "file_change" || workEntryTitle === "file change")) { + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + } + if (workEntry.detail) return workEntry.detail; if (!firstPath) return null; return workEntry.changedFiles!.length === 1 ? firstPath : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; } +function workEntryTitleKey(workEntry: Pick): string { + const title = workEntry.toolTitle ?? workEntry.label; + return title.trim().toLowerCase(); +} + function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; @@ -827,6 +841,22 @@ function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.itemType === "web_search") return GlobeIcon; if (workEntry.itemType === "image_view") return EyeIcon; + const toolTitle = workEntryTitleKey(workEntry); + if (toolTitle === "read file" || toolTitle === "view file" || toolTitle === "image view") { + return EyeIcon; + } + if ( + toolTitle === "glob" || + toolTitle === "grep" || + toolTitle === "search files" || + toolTitle === "list directory" + ) { + return SearchIcon; + } + if (toolTitle === "file change") { + return SquarePenIcon; + } + switch (workEntry.itemType) { case "mcp_tool_call": return WrenchIcon;