From 6e8e5c9f46882e90760dd2e2769acf0128be5f33 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:38:46 +0200 Subject: [PATCH 01/10] feat(contracts): add GitHub Copilot settings schema CopilotSettings mirrors the other opt-in CLI providers (enabled flag, binaryPath, customModels) and wires into ServerSettings.providers and the settings patch map so instances can be configured from Settings. --- packages/contracts/src/settings.ts | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..94e6f96d07aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -467,6 +467,32 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const CopilotSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): the ACP binding is in public + // preview upstream. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("copilot").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the GitHub Copilot CLI binary.", + providerSettingsForm: { placeholder: "copilot", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type CopilotSettings = typeof CopilotSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -660,6 +686,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -800,6 +827,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CopilotSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -848,6 +881,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + copilot: Schema.optionalKey(CopilotSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), From 3308787e40188601ffe5d8ff8142b3fdec6751a8 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:39:08 +0200 Subject: [PATCH 02/10] feat(server): extend shared ACP runtime for command skills and late-starting agents Three capabilities the Copilot ACP binding needs, all provider-neutral: - session/update available_commands_update now parses into an AvailableCommandsChanged event (skills arrive as slash commands). - Session updates that land before startup settles are buffered and re-dispatched to the root session instead of dropped; Copilot advertises commands immediately after session/new. The load-replay idle gate keeps receiving touches while buffered. - First-sighting tool calls that carry rawInput are no longer suppressed until detail arrives, so subagent launches announce themselves; input-less placeholders stay suppressed. An empty authMethodId skips the authenticate round-trip for agents that auth outside ACP. --- .../src/provider/acp/AcpRuntimeModel.ts | 29 ++++++++ .../src/provider/acp/AcpSessionRuntime.ts | 74 ++++++++++++++++--- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..2dfea74fc3de 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -80,6 +80,12 @@ export interface AcpPermissionRequest { readonly toolCall?: AcpToolCallState; } +export interface AcpAvailableCommand { + readonly name: string; + readonly description?: string; + readonly inputHint?: string; +} + export type AcpParsedSessionEvent = | { readonly _tag: "ModeChanged"; @@ -108,6 +114,10 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "AvailableCommandsChanged"; + readonly commands: ReadonlyArray; }; type AcpSessionSetupResponse = @@ -574,6 +584,25 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "available_commands_update": { + const commands: Array = []; + for (const command of upd.availableCommands) { + const name = command.name.trim(); + if (!name) { + continue; + } + commands.push({ + name, + ...(command.description?.trim() ? { description: command.description.trim() } : {}), + ...(command.input?.hint?.trim() ? { inputHint: command.input.hint.trim() } : {}), + }); + } + events.push({ + _tag: "AvailableCommandsChanged", + commands, + }); + break; + } default: break; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..390becd9dbeb 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -367,7 +367,12 @@ export const make = ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); - yield* acp.handleSessionUpdate((notification) => + // Updates can legitimately arrive before session setup settles (e.g. + // Copilot advertises slash-command skills right after session/new). + // Buffer them and re-dispatch once the root session id is known. + const preStartUpdatesRef = yield* Ref.make>([]); + + const processStartedNotification = (notification: EffectAcpSchema.SessionNotification) => Effect.gen(function* () { const gate = yield* Ref.get(sessionLoadGateRef); if (Option.isSome(gate) && gate.value.active) { @@ -401,6 +406,42 @@ export const make = ( assistantItemRuntimeId, params: notification, }); + }); + + const drainPreStartUpdates = (rootSessionId: string) => + Effect.gen(function* () { + const buffered = yield* Ref.getAndSet(preStartUpdatesRef, []); + yield* Effect.forEach( + buffered, + (notification) => + notification.sessionId === rootSessionId + ? processStartedNotification(notification) + : Effect.void, + { discard: true }, + ); + }); + + yield* acp.handleSessionUpdate((notification) => + Effect.gen(function* () { + const startState = yield* Ref.get(startStateRef); + if (startState._tag !== "Started") { + yield* Ref.update(preStartUpdatesRef, (buffer) => [...buffer, notification]); + // The session/load idle detector counts every gated touch, replays + // included; buffered traffic must keep feeding it. + const gate = yield* Ref.get(sessionLoadGateRef); + if (Option.isSome(gate) && gate.value.active) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + } + return; + } + yield* processStartedNotification(notification); }), ); const initializeClientCapabilities = { @@ -541,15 +582,20 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + // Agents that handle auth entirely outside ACP (GitHub login, cached + // tokens) advertise no auth methods; drivers signal that with an empty + // authMethodId and the handshake skips the authenticate round-trip. + if (options.authMethodId.trim().length > 0) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -672,6 +718,7 @@ export const make = ( startOnce.pipe( Effect.tap((result) => Ref.set(startStateRef, { _tag: "Started", result }).pipe( + Effect.andThen(drainPreStartUpdates(result.sessionId)), Effect.andThen(Deferred.succeed(deferred, result)), ), ), @@ -934,7 +981,14 @@ function shouldEmitToolCallUpdate( return true; } if (!next.detail) { - return false; + // First sighting of a tool call carrying provider-supplied input (a + // subagent launch, a command) announces itself even before detail + // content streams in. Input-less placeholders stay suppressed. + if (previous !== undefined) { + return false; + } + const rawInput = next.data.rawInput; + return typeof rawInput === "object" && rawInput !== null && Object.keys(rawInput).length > 0; } return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } From 8ab161e2a5d74d64b8f11b10f9beef365d6f6ee1 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:39:34 +0200 Subject: [PATCH 03/10] test(server): mock ACP flows for commands, subagents and background tasks Three opt-in flags for the shared mock agent: emit available_commands_update after session/new, a foreground task tool call, and the copilot-cli background-agent shape (launch, early end_turn, then post-turn chunks plus an idle report). --- apps/server/scripts/acp-mock-agent.ts | 131 +++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 5 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..33eb0537fda3 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -36,6 +36,9 @@ const emitStaleXAiPromptCompleteBeforeSecondHang = const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; +const emitAvailableCommands = process.env.T3_ACP_EMIT_AVAILABLE_COMMANDS === "1"; +const emitTaskToolCall = process.env.T3_ACP_EMIT_TASK_TOOL_CALL === "1"; +const emitBackgroundTask = process.env.T3_ACP_EMIT_BACKGROUND_TASK === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -310,11 +313,29 @@ const program = Effect.gen(function* () { yield* agent.handleAuthenticate(() => Effect.succeed({})); yield* agent.handleCreateSession(() => - Effect.succeed({ - sessionId, - modes: modeState(), - models: modelState(), - configOptions: configOptions(), + Effect.gen(function* () { + if (emitAvailableCommands) { + yield* agent.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "research", + description: "Deep research on a topic", + input: { hint: "topic to research" }, + }, + { name: "plan", description: "Create an implementation plan" }, + ], + }, + }); + } + return { + sessionId, + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; }), ); @@ -630,6 +651,106 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitBackgroundTask) { + // Mirrors copilot-cli background agents: the launch tool_call is + // followed by an early `end_turn` response, then the agent keeps + // streaming progress and an idle report after the RPC has settled. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "task-bg-1", + title: "Wait and write hello", + kind: "other", + status: "pending", + rawInput: { + agent_type: "task", + name: "delayed-hello", + mode: "background", + description: "Wait and write hello", + }, + }, + }); + + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("150 millis"); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "wake-read-1", + title: "read_agent", + kind: "read", + status: "pending", + rawInput: { agent_id: "delayed-hello", since_turn: 0 }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "wake-read-1", + status: "completed", + rawOutput: { + content: + "Agent is idle (waiting for messages). agent_id: delayed-hello, agent_type: task, status: idle, elapsed: 19s", + }, + }, + }); + }), + ); + + return { stopReason: "end_turn" }; + } + + if (emitTaskToolCall) { + const taskToolCallId = "task-tool-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: taskToolCallId, + title: "task", + kind: "other", + status: "pending", + rawInput: { + agent: "researcher", + prompt: "explore the codebase", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: taskToolCallId, + status: "in_progress", + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: taskToolCallId, + status: "completed", + rawOutput: { summary: "research done" }, + }, + }); + + return { stopReason: "end_turn" }; + } + if (emitToolCalls) { const toolCallId = "tool-call-1"; From 987710d335bfe9f359e5225112ec4365be5989d5 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:39:35 +0200 Subject: [PATCH 04/10] feat(server): Copilot ACP connection support Spawn input for `copilot --acp`, a runtime factory that skips ACP authenticate (GitHub login/BYOK happen outside the protocol), and model selection helpers mirroring the Grok binding. --- .../src/provider/acp/CopilotAcpSupport.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/server/src/provider/acp/CopilotAcpSupport.ts diff --git a/apps/server/src/provider/acp/CopilotAcpSupport.ts b/apps/server/src/provider/acp/CopilotAcpSupport.ts new file mode 100644 index 000000000000..f6854cc76f9f --- /dev/null +++ b/apps/server/src/provider/acp/CopilotAcpSupport.ts @@ -0,0 +1,96 @@ +import { type CopilotSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as Crypto from "effect/Crypto"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const COPILOT_DRIVER_KIND = ProviderDriverKind.make("copilot"); + +type CopilotAcpRuntimeSettings = Pick; + +interface CopilotAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly copilotSettings: CopilotAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildCopilotAcpSpawnInput( + copilotSettings: CopilotAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: copilotSettings?.binaryPath || "copilot", + args: ["--acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeCopilotAcpRuntime = ( + input: CopilotAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildCopilotAcpSpawnInput(input.copilotSettings, input.cwd, input.environment), + // Copilot authenticates outside ACP (GitHub login or BYOK env); an + // empty auth method id makes the shared runtime skip `authenticate`. + authMethodId: "", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveCopilotBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + if (!trimmed) { + return "copilot-default"; + } + return normalizeModelSlug(trimmed, COPILOT_DRIVER_KIND) ?? trimmed; +} + +export function currentCopilotModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyCopilotModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} From 0290a0ffc5888c14d847ca9ed188ca6604aaef33 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:01 +0200 Subject: [PATCH 05/10] feat(server): Copilot adapter projects subagents onto the task lifecycle Turn loop follows the Grok ACP adapter (prompt steering, approvals, cancel) minus the xAI quirks. Two Copilot-specific behaviors: - task tool calls (rawInput.agent_type) emit task.started/progress/ completed with agentKind "agent" so they render on the Agents surface instead of opaque tool rows. - Background launches (mode: "background") park the turn settlement: copilot-cli answers end_turn early and keeps streaming progress, so the turn stays routable until a follow-up call reports the agent idle; then task.completed flushes the parked turn.completed. Stop cancels outright; a new sendTurn closes leftovers as stopped. --- .../src/provider/Layers/CopilotAdapter.ts | 1642 +++++++++++++++++ .../src/provider/Services/CopilotAdapter.ts | 16 + 2 files changed, 1658 insertions(+) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.ts create mode 100644 apps/server/src/provider/Services/CopilotAdapter.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 000000000000..d2c6dbce82b9 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,1642 @@ +import { + ApprovalRequestId, + type CopilotSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + RuntimeTaskId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import type { AcpToolCallState } from "../acp/AcpRuntimeModel.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyCopilotModelSelection, + currentCopilotModelIdFromSessionSetup, + makeCopilotAcpRuntime, + resolveCopilotBaseModelId, +} from "../acp/CopilotAcpSupport.ts"; +import { type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface CopilotAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface CopilotSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + /** Subagent tool calls already announced with `task.started`. */ + readonly startedSubagentTaskIds: Set; + /** + * Background subagents (`mode: "background"`) still running. Copilot ends + * the ACP prompt early for these ("Subagent started…" + end_turn) and keeps + * streaming the agent's progress afterwards, so the turn is held open until + * every launch reports idle — otherwise the reply text and the real work + * duration land after the UI closed the turn. + */ + readonly heldOpenTaskIds: Set; + /** Launched background agent name -> task id, for completion matching. */ + readonly taskIdsByName: Map; + /** + * Completed-turn settlement parked while background tasks run; flushed + * when the last task goes idle. + */ + pendingTurnStop: + | { + readonly turnId: TurnId; + readonly stopReason: EffectAcpSchema.StopReason | null; + } + | undefined; + stopped: boolean; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Detects Copilot's `task` subagent tool calls so they render on the Agents + * surface instead of the tool timeline. + * + * Ground truth (copilot-cli 1.0.80): launches carry + * `rawInput.agent_type: "task"` plus `name`/`mode`. Older key/title + * heuristics kept as fallbacks. + * + * ponytail: no structured agent-tool marker in ACP yet — tighten upstream. + */ +export function copilotToolCallIsSubagent(toolCall: AcpToolCallState): boolean { + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput)) { + for (const key of ["agent_type", "agent", "agentName", "agent_name"] as const) { + const value = rawInput[key]; + if (typeof value === "string" && value.trim().length > 0) { + return true; + } + } + } + return /^task\b/i.test(toolCall.title?.trim() ?? ""); +} + +function subagentNameFromToolCall(toolCall: AcpToolCallState): string | undefined { + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput)) { + for (const key of ["name", "agent", "agentName", "agent_name"] as const) { + const value = rawInput[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + } + return undefined; +} + +/** + * Completion signal for a tracked background subagent: a follow-up tool call + * scoped to that agent (`rawInput.agent_id`) whose output reports it idle or + * finished. Observed via `read_agent` ("Agent is idle … status: idle …"). + * + * ponytail: prose-matching until Copilot ACP pushes structured lifecycle. + */ +export function copilotBackgroundTaskCompletion( + toolCall: AcpToolCallState, + taskIdsByName: ReadonlyMap, +): string | undefined { + const rawInput = toolCall.data.rawInput; + if (!isRecord(rawInput)) { + return undefined; + } + const agentId = typeof rawInput.agent_id === "string" ? rawInput.agent_id.trim() : ""; + if (!agentId) { + return undefined; + } + const taskId = taskIdsByName.get(agentId); + if (taskId === undefined) { + return undefined; + } + const rawOutput = toolCall.data.rawOutput; + const text = + (isRecord(rawOutput) && + [rawOutput.content, rawOutput.detailedContent] + .filter((value): value is string => typeof value === "string") + .join("\n")) || + ""; + return /status:\s*(idle|done|completed|failed)/i.test(text) ? taskId : undefined; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: CopilotSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +const resolveNotificationTurnId = (ctx: CopilotSessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? ctx.activeTurnId : undefined; +}; + +function parseCopilotResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== COPILOT_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +export function makeCopilotAdapter( + copilotSettings: CopilotSettings, + options?: CopilotAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Copilot runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Copilot ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = + liveCtx.acpSessionId === expectedAcpSessionId && + (liveCtx.activeTurnId === turnId || liveCtx.session.activeTurnId === turnId); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* parkTurnCompletionWhileTasksRun( + threadId, + settleTurnId, + yield* makeEventStamp(), + options.completedStopReason ?? null, + ); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Copilot notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: CopilotSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + /** + * Projects a Copilot subagent (`task` tool call) onto the shared task + * lifecycle so the Agents surface shows it running instead of rendering + * it as an opaque dynamic tool row. + */ + const emitSubagentTaskEvents = ( + ctx: CopilotSessionContext, + turnId: TurnId, + toolCall: AcpToolCallState, + ) => + Effect.gen(function* () { + const taskId = RuntimeTaskId.make(toolCall.toolCallId); + const agentName = subagentNameFromToolCall(toolCall); + const description = agentName ?? toolCall.title ?? "subagent"; + const linkage = { + taskType: "subagent", + agentKind: "agent" as const, + title: description, + }; + const terminalStatus = + toolCall.status === "completed" + ? ("completed" as const) + : toolCall.status === "failed" + ? ("failed" as const) + : undefined; + if (terminalStatus) { + ctx.heldOpenTaskIds.delete(taskId); + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { + taskId, + status: terminalStatus, + ...linkage, + }, + }); + yield* flushHeldTurnCompletion(ctx.threadId); + return; + } + if (!ctx.startedSubagentTaskIds.has(taskId)) { + ctx.startedSubagentTaskIds.add(taskId); + if (agentName) { + ctx.taskIdsByName.set(agentName, taskId); + } + ctx.heldOpenTaskIds.add(taskId); + yield* offerRuntimeEvent({ + type: "task.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { + taskId, + description, + ...linkage, + }, + }); + return; + } + yield* offerRuntimeEvent({ + type: "task.progress", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { + taskId, + description, + status: "running", + ...linkage, + }, + }); + }); + + /** + * Emits a completed turn unless background subagents are still streaming; + * Copilot keeps working after answering (`end_turn` + continued session + * updates), so the settlement parks until the last launch goes idle. + */ + const parkTurnCompletionWhileTasksRun = ( + threadId: ThreadId, + turnId: TurnId, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + stopReason: EffectAcpSchema.StopReason | null, + ) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (stopReason !== "cancelled" && ctx && ctx.heldOpenTaskIds.size > 0) { + ctx.pendingTurnStop = { turnId, stopReason }; + // Keep the turn routable: post-answer subagent traffic resolves its + // turn id from these fields. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: stamp.createdAt, + }; + return; + } + yield* offerRuntimeEvent({ + type: "turn.completed", + ...stamp, + provider: PROVIDER, + threadId, + turnId, + payload: { + state: stopReason === "cancelled" ? "cancelled" : "completed", + stopReason, + }, + }); + }); + + const flushHeldTurnCompletion = (threadId: ThreadId) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx || ctx.heldOpenTaskIds.size > 0) { + return; + } + const pending = ctx.pendingTurnStop; + if (!pending) { + return; + } + ctx.pendingTurnStop = undefined; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: pending.turnId, + payload: { + state: "completed", + stopReason: pending.stopReason, + }, + }); + if (ctx.activeTurnId === pending.turnId || ctx.session.activeTurnId === pending.turnId) { + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: yield* nowIso, + }; + } + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: CopilotSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: CopilotAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const copilotModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + // Copilot advertises loadSession:false, so a resume cursor is kept + // for forward compatibility but never replayed into the agent. + void parseCopilotResume(input.resumeCursor); + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeCopilotAcpRuntime({ + copilotSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = copilotModelSelection?.model + ? resolveCopilotBaseModelId(copilotModelSelection.model) + : undefined; + const boundModelId = yield* applyCopilotModelSelection({ + runtime: acp, + currentModelId: currentCopilotModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveCopilotBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: COPILOT_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: CopilotSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + startedSubagentTaskIds: new Set(), + heldOpenTaskIds: new Set(), + taskIdsByName: new Map(), + pendingTurnStop: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "AvailableCommandsChanged": + // Skills refresh live in-session is not wired yet; the + // settings probe owns skill discovery. + return; + case "ToolCallUpdated": { + const finishedTaskId = copilotBackgroundTaskCompletion( + event.toolCall, + ctx.taskIdsByName, + ); + if ( + finishedTaskId !== undefined && + ctx.heldOpenTaskIds.has(RuntimeTaskId.make(finishedTaskId)) + ) { + // The agent reported idle/completed; close the tracked + // task and let the parked turn settle. The diagnostic + // tool row itself is not rendered. + yield* emitSubagentTaskEvents(ctx, notificationTurnId, { + ...event.toolCall, + toolCallId: finishedTaskId, + status: "completed", + }); + return; + } + if (copilotToolCallIsSubagent(event.toolCall)) { + yield* emitSubagentTaskEvents(ctx, notificationTurnId, event.toolCall); + return; + } + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + } + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Copilot runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "GitHub Copilot ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A new turn supersedes the previous one's background bookkeeping: + // leftover tasks close as stopped and a parked settlement flushes + // before the new turn opens. + for (const staleTaskId of Array.from(ctx.heldOpenTaskIds)) { + ctx.heldOpenTaskIds.delete(staleTaskId); + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { + taskId: RuntimeTaskId.make(staleTaskId), + status: "stopped", + taskType: "subagent", + agentKind: "agent", + }, + }); + } + const parkedStop = ctx.pendingTurnStop; + if (parkedStop) { + ctx.pendingTurnStop = undefined; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: parkedStop.turnId, + payload: { + state: "completed", + stopReason: parkedStop.stopReason, + }, + }); + } + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveCopilotBaseModelId(turnModelSelection.model) + : undefined; + const currentModelId = yield* applyCopilotModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveCopilotBaseModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Copilot prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Copilot prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Copilot session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Copilot session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + yield* parkTurnCompletionWhileTasksRun( + input.threadId, + prepared.turnId, + yield* makeEventStamp(), + result.stopReason ?? null, + ); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Copilot session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: promptResult.stopReason, + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Copilot prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + // Cancellation wins over parked background settlements. + ctx.heldOpenTaskIds.clear(); + ctx.pendingTurnStop = undefined; + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const readThread: CopilotAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Copilot ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "tool_user_input", + detail: "Copilot does not emit structured user-input requests over ACP.", + }), + ), + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies CopilotAdapterShape; + }); +} diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 000000000000..3ca9391b73cf --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,16 @@ +/** + * CopilotAdapter — shape type for the GitHub Copilot provider adapter. + * + * The driver model ({@link ../Drivers/CopilotDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module CopilotAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * CopilotAdapterShape — per-instance GitHub Copilot adapter contract. + */ +export interface CopilotAdapterShape extends ProviderAdapterShape {} From b07f41e705ae0ea91dacf1f86546649aed6fffde Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:02 +0200 Subject: [PATCH 06/10] feat(server): discover Copilot models and slash-command skills via ACP probe Status probe runs `copilot --version` then one short-lived ACP session that collects the model list from session setup and skills from available_commands_update. Skills map onto ServerProviderSkill with the invocation (`/name`) as path since upstream exposes no skill files. --- .../src/provider/Layers/CopilotProvider.ts | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 apps/server/src/provider/Layers/CopilotProvider.ts diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts new file mode 100644 index 000000000000..72b7d8bbc87d --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,378 @@ +import { + type CopilotSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import type { AcpAvailableCommand } from "../acp/AcpRuntimeModel.ts"; +import { makeCopilotAcpRuntime, resolveCopilotBaseModelId } from "../acp/CopilotAcpSupport.ts"; + +const COPILOT_PRESENTATION = { + displayName: "GitHub Copilot", + badgeLabel: "Preview", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const COPILOT_ACP_DISCOVERY_TIMEOUT_MS = 25_000; +const COPILOT_ACP_COMMANDS_WAIT = Duration.seconds(8); + +export function buildInitialCopilotProviderSnapshot( + copilotSettings: CopilotSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = copilotModelsFromSettings(copilotSettings.customModels); + + if (!copilotSettings.enabled) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "GitHub Copilot is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking GitHub Copilot CLI availability...", + }, + }); + }); +} + +function copilotModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function buildCopilotDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveCopilotBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +/** + * Maps ACP `available_commands_update` entries onto the shared skill shape. + * + * ponytail: Copilot skills are slash commands over ACP, so `path` carries the + * invocation (`/name`) rather than a file path. Swap to real paths when + * upstream exposes skill files. + */ +export function copilotSkillsFromCommands( + commands: ReadonlyArray, +): ReadonlyArray { + return commands.map((command) => ({ + name: command.name, + ...(command.description ? { description: command.description } : {}), + path: `/${command.name}`, + scope: "agent", + enabled: true, + })); +} + +interface CopilotAcpDiscovery { + readonly models: ReadonlyArray; + readonly commands: ReadonlyArray; +} + +export const discoverCopilotViaAcp = ( + copilotSettings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeCopilotAcpRuntime({ + copilotSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + const models = buildCopilotDiscoveredModelsFromSessionModelState( + started.sessionSetupResult.models, + ); + const commandsDeferred = yield* Deferred.make>(); + const commandsConsumer = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + event._tag === "AvailableCommandsChanged" + ? Deferred.succeed(commandsDeferred, event.commands).pipe(Effect.asVoid) + : Effect.void, + ), + ).pipe(Effect.forkScoped); + const commandsUpdate = yield* Deferred.await(commandsDeferred).pipe( + Effect.timeoutOption(COPILOT_ACP_COMMANDS_WAIT), + ); + yield* Fiber.interrupt(commandsConsumer); + return { + models, + commands: Option.getOrElse(commandsUpdate, () => []), + } satisfies CopilotAcpDiscovery; + }).pipe(Effect.scoped); + +const runCopilotVersionCommand = ( + copilotSettings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = copilotSettings.binaryPath || "copilot"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkCopilotProviderStatus = Effect.fn("checkCopilotProviderStatus")(function* ( + copilotSettings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = copilotModelsFromSettings(copilotSettings.customModels); + + if (!copilotSettings.enabled) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "GitHub Copilot is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runCopilotVersionCommand(copilotSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("GitHub Copilot CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "GitHub Copilot CLI (`copilot`) is not installed or not on PATH." + : "Failed to execute GitHub Copilot CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "GitHub Copilot CLI is installed but timed out while running `copilot --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("GitHub Copilot CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "GitHub Copilot CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverCopilotViaAcp(copilotSettings, environment).pipe( + Effect.timeoutOption(COPILOT_ACP_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("GitHub Copilot ACP discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "GitHub Copilot CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `GitHub Copilot ACP discovery timed out after ${COPILOT_ACP_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `GitHub Copilot CLI is installed but ACP startup timed out after ${COPILOT_ACP_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discovered = discoveryExit.value.value; + const models = + discovered.models.length > 0 + ? copilotModelsFromSettings(copilotSettings.customModels, discovered.models) + : fallbackModels; + const skills = copilotSkillsFromCommands(discovered.commands); + + return buildServerProvider({ + presentation: COPILOT_PRESENTATION, + enabled: copilotSettings.enabled, + checkedAt, + models, + skills, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichCopilotSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("GitHub Copilot version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; From 06b39ac20dc5bf4a0c8bcf963d16d5fb83a06cd2 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:43 +0200 Subject: [PATCH 07/10] feat(server): register the GitHub Copilot driver CopilotDriver wires adapter, snapshot maintenance and text generation; text generation intentionally runs on the session's current model instead of pinning one. Driver added to BUILT_IN_DRIVERS. --- .../src/provider/Drivers/CopilotDriver.ts | 163 ++++++++++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/CopilotTextGeneration.ts | 243 ++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 apps/server/src/provider/Drivers/CopilotDriver.ts create mode 100644 apps/server/src/textGeneration/CopilotTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 000000000000..978d7ab960c6 --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,163 @@ +import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCopilotAdapter } from "../Layers/CopilotAdapter.ts"; +import { + buildInitialCopilotProviderSnapshot, + checkCopilotProviderStatus, + enrichCopilotSnapshot, +} from "../Layers/CopilotProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); + +const DRIVER_KIND = ProviderDriverKind.make("copilot"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type CopilotDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "GitHub Copilot", + supportsMultipleInstances: true, + }, + configSchema: CopilotSettings, + defaultConfig: (): CopilotSettings => decodeCopilotSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CopilotSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeCopilotAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkCopilotProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialCopilotProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichCopilotSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build GitHub Copilot snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..632ab411532b 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -22,6 +22,7 @@ */ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; @@ -35,6 +36,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv + | CopilotDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -47,6 +49,7 @@ export type BuiltInDriversEnv = export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, ClaudeDriver, + CopilotDriver, CursorDriver, GrokDriver, OpenCodeDriver, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts new file mode 100644 index 000000000000..af25b1ab9a22 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -0,0 +1,243 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type CopilotSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { makeCopilotAcpRuntime } from "../provider/acp/CopilotAcpSupport.ts"; + +const COPILOT_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")(function* ( + copilotSettings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runCopilotJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + // ponytail: modelSelection ignored — Copilot text generation runs on the + // session's current model; a wrong guess would fail `session/set_model`. + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeCopilotAcpRuntime({ + copilotSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(COPILOT_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Copilot ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Copilot ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Copilot ACP request was cancelled." + : "Copilot Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Copilot Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Copilot ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CopilotTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CopilotTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CopilotTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CopilotTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runCopilotJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); From e5c86c59d9944a0c1f8b22de4ddce68be26bd7d0 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:44 +0200 Subject: [PATCH 08/10] test(server): cover Copilot turn flow, subagent projection and skills Mock-agent driven: basic prompt streaming, subagent tool calls emitting task events (no double render), background flow holding the turn open until idle, and skills collection from available_commands_update. --- .../provider/Layers/CopilotAdapter.test.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.test.ts 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 000000000000..e6a6cdb97771 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,264 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + CopilotSettings, + ProviderDriverKind, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { copilotSkillsFromCommands, discoverCopilotViaAcp } from "./CopilotProvider.ts"; +import { makeCopilotAdapter } from "./CopilotAdapter.ts"; + +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockCopilotWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "copilot-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-copilot.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +const copilotAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeCopilotAdapter(decodeCopilotSettings({ binaryPath }), options).pipe(Effect.orDie); + +it.layer(copilotAdapterTestLayer)("CopilotAdapterLive", (it) => { + it.effect("starts a session and maps the mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("copilot-mock-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockCopilotWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("copilot"), + cwd: process.cwd(), + runtimeMode: "auto", + }); + + assert.equal(session.provider, "copilot"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello copilot", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "content.delta", + "turn.completed", + ] as const); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("projects Copilot subagent tool calls onto the task lifecycle", () => + Effect.gen(function* () { + const threadId = ThreadId.make("copilot-subagent-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockCopilotWrapper({ T3_ACP_EMIT_TASK_TOOL_CALL: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("copilot"), + cwd: process.cwd(), + runtimeMode: "auto", + }); + yield* adapter.sendTurn({ + threadId, + input: "use the researcher subagent", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + // Task completion rides the event stream; give the fold a beat to drain. + yield* adapter.readThread(threadId); + yield* Fiber.interrupt(runtimeEventsFiber); + + const taskStarted = runtimeEvents.find((e) => e.type === "task.started"); + const taskCompleted = runtimeEvents.find((e) => e.type === "task.completed"); + assert.isDefined(taskStarted); + assert.isDefined(taskCompleted); + if (taskStarted?.type === "task.started") { + assert.equal(taskStarted.payload.taskId, "task-tool-1"); + assert.equal(taskStarted.payload.description, "researcher"); + assert.equal(taskStarted.payload.agentKind, "agent"); + } + if (taskCompleted?.type === "task.completed") { + assert.equal(taskCompleted.payload.taskId, "task-tool-1"); + assert.equal(taskCompleted.payload.status, "completed"); + } + + // The subagent tool call must not double-render on the tool timeline. + const itemEventsForToolCall = runtimeEvents.filter( + (e) => + (e.type === "item.updated" || e.type === "item.completed") && e.itemId === "task-tool-1", + ); + assert.isEmpty(itemEventsForToolCall); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("holds the turn open until a background subagent reports idle", () => + Effect.gen(function* () { + const threadId = ThreadId.make("copilot-background-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockCopilotWrapper({ T3_ACP_EMIT_BACKGROUND_TASK: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: Array<{ readonly type: ProviderRuntimeEvent["type"] }> = []; + const turnCompleted = yield* Deferred.make(); + const helloSeen = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push({ type: event.type }); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + Effect.andThen( + event.type === "content.delta" ? Deferred.succeed(helloSeen, undefined) : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("copilot"), + cwd: process.cwd(), + runtimeMode: "auto", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "run delayed-hello in the background", + attachments: [], + }); + + // Copilot answers `end_turn` while the background agent still runs. + assert.isDefined(turn); + yield* Deferred.await(helloSeen); + // The post-answer traffic must land inside a still-open turn... + const deltaIndex = runtimeEvents.findIndex((e) => e.type === "content.delta"); + const completedIndex = runtimeEvents.findIndex((e) => e.type === "turn.completed"); + assert.isTrue(deltaIndex >= 0); + assert.isTrue(completedIndex < 0 || completedIndex > deltaIndex); + + // ...and the idle report closes both the task and the turn. + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + assert.isTrue(runtimeEvents.some((e) => e.type === "task.started")); + assert.isTrue(runtimeEvents.some((e) => e.type === "task.completed")); + const finalCompletedIndex = runtimeEvents.findIndex((e) => e.type === "turn.completed"); + assert.isTrue(finalCompletedIndex > deltaIndex); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.timeoutOption(15_000)), + ); + + it.effect("collects skills advertised through available_commands_update", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => + makeMockCopilotWrapper({ T3_ACP_EMIT_AVAILABLE_COMMANDS: "1" }), + ); + const discovery = yield* discoverCopilotViaAcp( + decodeCopilotSettings({ binaryPath: wrapperPath }), + ); + + assert.deepEqual( + discovery.commands.map((command) => command.name), + ["research", "plan"], + ); + assert.equal(discovery.commands[0]?.inputHint, "topic to research"); + + const skills = copilotSkillsFromCommands(discovery.commands); + assert.equal(skills.length, 2); + assert.equal(skills[0]?.name, "research"); + assert.equal(skills[0]?.path, "/research"); + assert.equal(skills[0]?.enabled, true); + }), + ); +}); From 2b6c50e74461e78f06ed2cac4e62965eed6e8da0 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:45 +0200 Subject: [PATCH 09/10] feat(clients): surface GitHub Copilot in pickers, settings and icons Picker entry, browser-safe driver metadata with a preview badge, GitHub mark icons for web and mobile. --- apps/mobile/src/components/ProviderIcon.tsx | 11 +++++++++++ apps/web/src/components/Icons.tsx | 10 ++++++++++ .../src/components/chat/providerIconUtils.ts | 3 ++- .../components/settings/providerDriverMeta.ts | 18 +++++++++++++++++- apps/web/src/session-logic.ts | 6 ++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..89bdda333db6 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -39,6 +39,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "copilot") { + return ( + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..8d4b0c7374f2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -202,6 +202,16 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); +export const CopilotIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GrokIcon: Icon = ({ className, ...props }) => ( > = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("copilot")]: CopilotIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..bff24e35dcf9 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,13 +1,22 @@ import { ClaudeSettings, CodexSettings, + CopilotSettings, CursorSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CopilotIcon, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + icon: CopilotIcon, + badgeLabel: "Preview", + settingsSchema: CopilotSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..e5aceb1f0ee2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = From 714a699a8d65a8f3b7c38a9ec7e483ae24da8e89 Mon Sep 17 00:00:00 2001 From: Tasi Date: Sat, 22 Aug 2026 11:40:46 +0200 Subject: [PATCH 10/10] docs: describe the copilot ACP driver Built-in driver table gains copilot plus a note on command-based skills and background-task turn holds. --- docs/internals/providers.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..eac59f5bc018 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,12 +7,13 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | | `codex` | [`Drivers/CodexDriver.ts`][codex] | | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `copilot` | [`Drivers/CopilotDriver.ts`][copilot] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | @@ -23,6 +24,12 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +The `copilot` driver speaks ACP end to end (`copilot --acp`) with no provider-specific protocol +layer: skills arrive as slash commands via `available_commands_update`, and subagent launches are +detected from Copilot's `task` tool-call input shape (`rawInput.agent_type`). Background launches +(`mode: "background"`) park the turn settlement until a follow-up call reports the agent idle, so +post-`end_turn` progress streams into the original turn (see `CopilotAdapter.ts`). + ## Registry and routing Two registries separate configuration from live processes: @@ -78,6 +85,7 @@ when a request opens (approval) or user input is requested, via [drivers]: ../../apps/server/src/provider/builtInDrivers.ts [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts +[copilot]: ../../apps/server/src/provider/Drivers/CopilotDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts