diff --git a/AGENTS.md b/AGENTS.md index b919730ebe749..d86ffecee6934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, GitHub Copilot) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, and GitHub Copilot each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code. - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts index 171d73a148597..320e3d3634696 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts @@ -326,6 +326,12 @@ describe("resolveSubagentStopTarget", () => { }), ).toBeNull(); expect(resolveSubagentStopTarget({ ...native, runId: null })).toBeNull(); + expect( + resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("githubCopilot") }), + ).toEqual({ kind: "subagent", input: { threadId: native.threadId, subagentId: native.id } }); + expect( + resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("acpRegistry") }), + ).toBeNull(); expect(resolveSubagentStopTarget({ ...native, status: "completed" })).toBeNull(); expect( resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("codex") }), diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index 7f560497da254..1bbd9a3d25b1d 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -9,11 +9,12 @@ import { type SubagentWorkflowGroup, } from "@t3tools/client-runtime/state/subagentRuntime"; import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; -import type { - EnvironmentId, - OrchestrationV2Subagent, - ServerProvider, - ThreadId, +import { + canStopNativeSubagent, + type EnvironmentId, + type OrchestrationV2Subagent, + type ServerProvider, + type ThreadId, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; @@ -238,13 +239,7 @@ export function resolveSubagentStopTarget( input: { threadId: subagent.threadId, subagentId: subagent.id }, }; } - if ( - subagent.origin === "provider_native" && - subagent.driver === "claudeAgent" && - subagent.runId !== null && - subagent.nativeTaskRef?.strength === "strong" && - subagent.nativeTaskRef.nativeId !== null - ) { + if (canStopNativeSubagent(subagent)) { return { kind: "subagent" as const, input: { threadId: subagent.threadId, subagentId: subagent.id }, diff --git a/apps/server/package.json b/apps/server/package.json index 3a6797d90da28..7fc68148abae7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,6 +45,7 @@ "@napi-rs/keyring": "^1.3.0", "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", + "@t3tools/provider-copilot": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-fx": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 807675034189c..dd108872eb211 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -3396,6 +3396,70 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("keeps a captured proposed plan actionable after its turn completes", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("acp-test-v2-captured-plan"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + proposedPlanMarkdown: () => "# Plan", + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const threadId = ThreadId.make("thread-acp-v2-captured-plan"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "plan", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-v2-captured-plan"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 1, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const plans = events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], + ); + assert.deepEqual( + plans.map((plan) => plan.status), + ["active"], + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -5140,6 +5204,223 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("rebuilds the snapshot and resends instructions after a native rewind", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test-native-rewind"); + const prompts: Array = []; + let loads = 0; + let extensions: AcpAdapterV2ExtensionContext | undefined; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + enablePostSettleContinuation: true, + rollbackSession: () => Effect.void, + registerExtensions: (context) => + Effect.sync(() => { + extensions = context; + }), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (nativeRuntime) => ({ + ...nativeRuntime, + prompt: (payload, options) => { + prompts.push(JSON.stringify(payload.prompt)); + return nativeRuntime.prompt(payload, options); + }, + loadSession: (sessionId, activation) => { + loads += 1; + return extensions! + .applyBackgroundTaskMutation({ sessionId, taskId: "task-1", status: "running" }) + .pipe(Effect.andThen(nativeRuntime.loadSession(sessionId, activation))); + }, + }), + }), + }, + selfInvocation, + continuationRequests: { offer: () => Effect.void }, + }); + const threadId = ThreadId.make("thread-acp-native-rewind"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-native-rewind"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const runTurn = Effect.fn("runTurn")(function* (ordinal: number) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal, + }), + ); + while ((yield* Queue.take(events)).type !== "turn.terminal"); + }); + yield* runTurn(1); + const [providerTurn] = (yield* runtime.readThreadSnapshot({ providerThread })).providerTurns; + assert.isDefined(providerTurn); + assert.strictEqual(loads, 0); + + yield* runtime.rollbackThread({ + providerThread, + providerThreadTurns: [], + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-acp-native-rewind"), + appRunOrdinal: 1, + providerTurn: { + ...providerTurn!, + nativeTurnRef: { + driver: ACP_TEST_DRIVER, + strength: "strong", + nativeId: "native-turn-1", + }, + }, + }, + }); + + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + assert.strictEqual(loads, 1); + assert.deepEqual(snapshot.providerTurns, []); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + + yield* runTurn(2); + assert.strictEqual(prompts.length, 2); + assert.strictEqual(prompts[1], prompts[0]); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect("rewinds the thread's own session, starting afresh if it cannot load", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test-rewind-target"); + const rewound: Array = []; + let activeSession = ""; + let loadFails = false; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + rollbackSession: (_runtime, sessionId) => + Effect.sync(() => { + rewound.push(sessionId); + }), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (nativeRuntime) => ({ + ...nativeRuntime, + loadSession: (_sessionId, activation) => + loadFails + ? Effect.fail( + new EffectAcpErrors.AcpTransportError({ cause: new Error("no session") }), + ) + : nativeRuntime.loadSession(activeSession, activation), + }), + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-rewind-target"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-rewind-target"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + activeSession = providerThread.nativeThreadRef!.nativeId!; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + while ((yield* Queue.take(events)).type !== "turn.terminal"); + const [providerTurn] = (yield* runtime.readThreadSnapshot({ providerThread })).providerTurns; + const rollback = { + providerThread: { + ...providerThread, + nativeThreadRef: { ...providerThread.nativeThreadRef!, nativeId: "other-session" }, + }, + providerThreadTurns: [], + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-acp-rewind-target"), + appRunOrdinal: 1, + providerTurn: { + ...providerTurn!, + nativeTurnRef: { + driver: ACP_TEST_DRIVER, + strength: "strong", + nativeId: "native-turn-1", + }, + }, + }, + } as const; + + yield* runtime.rollbackThread(rollback); + assert.deepEqual(rewound, ["other-session"]); + + loadFails = true; + const replaced = yield* runtime.rollbackThread(rollback); + assert.deepEqual(rewound, ["other-session"]); + assert.notStrictEqual(replaced.providerThread.nativeThreadRef?.nativeId, "other-session"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("quarantines callbacks from a failed rollback replacement before retrying", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 8ed4de3e9dea0..4b941024aba74 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -118,6 +118,40 @@ it("resolves automatic message delivery from authoritative provider capabilities ); }); +// regression: these messages failed outright on Copilot's SDK transport, which cannot restart +it("queues a message a provider can neither steer nor restart for", () => { + const projection = dispatchProjection( + capabilities((current) => ({ + ...current, + turns: { + ...current.turns, + supportsActiveSteering: true, + activeSteeringAcceptsAttachments: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + }, + })), + ); + const dispatch = (message: { hasAttachments: boolean; switchesProvider: boolean }) => + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "auto", + message, + ); + + assert.deepEqual(dispatch({ hasAttachments: false, switchesProvider: false }), { + type: "steer_active", + targetRunId: activeRunId, + }); + assert.deepEqual(dispatch({ hasAttachments: true, switchesProvider: false }), { + type: "queue_after_active", + }); + assert.deepEqual(dispatch({ hasAttachments: false, switchesProvider: true }), { + type: "queue_after_active", + }); +}); + it.each(["preparing", "starting"] as const)( "queues an automatic message while the handoff run is %s", (status) => { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 02f329e536feb..c2261a31d8815 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -93,6 +93,7 @@ export function resolveMessageDispatchIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, deliveryIntent?: "auto" | "steer" | "restart", + message?: { readonly hasAttachments: boolean; readonly switchesProvider: boolean }, ): MessageDispatchMode { const mode = resolveDeliveryIntent(projection, requestedMode, deliveryIntent); if (mode.type !== "steer_active") return mode; @@ -102,13 +103,17 @@ export function resolveMessageDispatchIntent( if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { return { type: "queue_after_active" }; } - // A provider that can neither steer nor restart its turn takes the message after the turn. + // A provider that can neither steer this message nor restart its turn takes it after the turn. const turns = target === undefined || !isActiveRun(target) ? undefined : providerTurnCapabilities(projection, target); + const canSteerMessage = + turns?.supportsActiveSteering === true && + message?.switchesProvider !== true && + !(message?.hasAttachments === true && turns.activeSteeringAcceptsAttachments === false); return turns !== undefined && - !turns.supportsActiveSteering && + !canSteerMessage && !(turns.supportsInterrupt && turns.supportsSteeringByInterruptRestart) && turns.supportsQueuedMessages ? { type: "queue_after_active" } diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index bcec0a13cf20a..cc8275ae7ba1a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4,6 +4,7 @@ import type { OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, } from "@t3tools/contracts"; +import { canStopNativeSubagent } from "@t3tools/contracts"; import { latestExecutedRun, latestRootProviderFailure, @@ -5830,6 +5831,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection, command.dispatchMode, command.deliveryIntent, + { + hasAttachments: command.attachments.length > 0, + switchesProvider: modelSelection.instanceId !== projection.thread.providerInstanceId, + }, ); if (dispatchMode.type === "steer_active") { const targetRunId = dispatchMode.targetRunId; @@ -11481,10 +11486,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( subagent === undefined || subagent.runId !== command.runId || - subagent.origin !== "provider_native" || - subagent.driver !== "claudeAgent" || - subagent.nativeTaskRef?.strength !== "strong" || - subagent.nativeTaskRef.nativeId === null + !canStopNativeSubagent(subagent) || + subagent.nativeTaskRef?.nativeId == null ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 18877c6b5424d..58fbe9977a306 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -19,6 +19,7 @@ import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { gitHubCopilotClient } from "@t3tools/provider-copilot/client"; import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; @@ -46,6 +47,7 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ cursorClient, grokClient, fxClient, + gitHubCopilotClient, kiroClient, museClient, openCodeClient, diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 6398210599b61..da99af5764edd 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -9,6 +9,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; +import { snapshotMcpApp } from "../mcpApps/McpAppSnapshot.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as DocumentText from "./DocumentText.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; @@ -46,6 +47,16 @@ export const layer = Layer.effect( }, Effect.provideService(FileSystem.FileSystem, fileSystem), ), + snapshotMcpApp: Effect.fn("ProviderHost.snapshotMcpApp")(function (input) { + return snapshotMcpApp({ ...input, attachmentsDir: config.attachmentsDir }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.catch((error) => + Effect.logWarning("MCP App snapshot failed; showing the tool output instead", { + reason: error._tag, + }).pipe(Effect.as(undefined)), + ), + ); + }), credentials: Effect.fn("ProviderHost.credentials")(function (namespace, bindingId) { return ProviderCredentialStore.make(namespace, bindingId).pipe( Effect.map((store) => ({ diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..3a185f68a19b4 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -15,6 +15,7 @@ import type { AnyProviderDriver, ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; +import { GitHubCopilotDriver, type GitHubCopilotDriverEnv } from "@t3tools/provider-copilot/server"; import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; export type BuiltInDriversEnv = @@ -25,6 +26,7 @@ export type BuiltInDriversEnv = | CursorDriverEnv | FxDriverEnv | GrokDriverEnv + | GitHubCopilotDriverEnv | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv @@ -38,6 +40,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ "antigravity", "fx", "kiro", + "githubCopilot", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index ce93b51a8ebaf..e2956a3ddea98 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -14,6 +14,7 @@ import { import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { gitHubCopilotClient } from "@t3tools/provider-copilot/client"; import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; @@ -34,6 +35,7 @@ const providerSecretFields = new Map([ ...[ cursorClient, grokClient, + gitHubCopilotClient, kiroClient, museClient, openCodeClient, diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index f392c652e05de..1faae70448643 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -33,6 +33,7 @@ import { isProviderNativeSubagentThread, NodeId, type EnvironmentId, + canStopNativeSubagent, type OrchestrationV2DelegatedFrom, type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, @@ -717,11 +718,7 @@ export function ThreadRelationshipsPanel(props: { const canStop = agent && (agent.origin === "app_owned" || - (agent.origin === "provider_native" && - agent.driver === "claudeAgent" && - agent.runId != null && - agent.nativeTaskRef?.strength === "strong" && - agent.nativeTaskRef.nativeId !== null && + (canStopNativeSubagent(agent) && ["pending", "running", "waiting"].includes(agent.nativeStatus))) && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4c890c05aa4e9..ca0bc240d1fca 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -712,7 +712,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Provider update checks", to: "/settings/maintenance", searchTerms: [ - "installed cli versions newer available codex claude cursor grok fx kiro opencode", + "installed cli versions newer available codex claude cursor grok fx kiro github copilot opencode", ], scope: "environment-defaults", }, @@ -922,7 +922,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok fx kiro opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", + "agents cli codex claude cursor grok fx kiro github copilot opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", ], }, { diff --git a/docs/README.md b/docs/README.md index 7ee82b0232f51..5faf174315dff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) - [Triage an issue](./user/triage.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Kiro](./user/providers-kiro.md) · [GitHub Copilot](./user/providers-copilot.md) · [Pi](./user/providers-pi.md) · [Muse Code](./user/providers-muse.md) · [fx](./user/providers-fx.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index 1b892851dd39a..c3d6c59a748f4 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -177,18 +177,19 @@ and enable the provider you want. Installation, login, and configuration belong to that environment's machine, even when you connect from a phone or another computer. -| Provider | Install and authenticate | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. | -| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | -| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | -| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | -| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | -| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | -| Antigravity | Install and sign in with Google from T3 Code's provider settings. | -| Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | -| Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | -| fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | +| Provider | Install and authenticate | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. | +| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | +| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | +| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Kiro | Install [Kiro CLI](https://kiro.dev/docs/cli/), then run `kiro-cli login`. | +| GitHub Copilot | Install [GitHub Copilot CLI](https://github.com/github/copilot-cli), then run `copilot login`. | +| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | +| Antigravity | Install and sign in with Google from T3 Code's provider settings. | +| Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | +| Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | +| fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -219,6 +220,7 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), [Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), +[GitHub Copilot](./providers-copilot.md), [Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). ## Next steps diff --git a/docs/user/providers-copilot.md b/docs/user/providers-copilot.md new file mode 100644 index 0000000000000..88e742a33a111 --- /dev/null +++ b/docs/user/providers-copilot.md @@ -0,0 +1,61 @@ +# GitHub Copilot + +T3 Code can run GitHub Copilot as its own provider, using the Copilot CLI you already have. This is +separate from Copilot in the [ACP Registry](./providers-acp.md), which keeps working as before. + +GitHub Copilot is an early-access provider, and it relies on an experimental Copilot API. + +## Set it up + +1. Install the [GitHub Copilot CLI](https://github.com/github/copilot-cli). +2. Run `copilot login`. +3. Enable **GitHub Copilot** in **Settings > Providers**. + +If `copilot` is not on your `PATH`, set **Binary path** in the provider's settings. + +## Steering a running turn + +Send a follow-up while Copilot works, with or without images. It stops the tool the main agent is +running and answers right away, while background agents keep working. + +## Approvals + +When Copilot asks to run something, type a reason in the composer to decline with feedback. Copilot +reads the reason and carries on. + +## Plans + +In Plan mode, Copilot's plan appears as a proposal and it waits for you. Choose **Implement** to run +it, or send feedback to refine it. + +## Reasoning + +Models that support it show a **Reasoning** level in the model picker. + +## Rewind, forks, compaction, and commands + +- **Edit from here** rewinds the conversation, and Copilot forgets the turns you removed. +- Fork a thread from any Copilot response. +- `/compact` compacts the conversation. +- Copilot's own slash commands appear in the composer. + +## MCP Apps + +MCP tools that come with an interface show it in the thread. + +## Background agents + +Stop a running background agent from **Lineage** without ending the turn. + +## Generated text + +Copilot can write thread titles, commit messages, branch names, pull request titles and +descriptions, and review guides when you select it for text generation in **Settings**. It is not +chosen automatically, because generated text uses your Copilot quota. + +Review guides can only read the checkout and run `git diff`, `git log`, and `git show`. They are not +available on Windows, so choose another model for them there. + +## If Copilot reports it is not compatible + +T3 Code needs a Copilot CLI that speaks the same protocol. If it says yours does not, update Copilot. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f52115577f108..1cd35e47c6f77 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -548,6 +548,7 @@ "@pierre/diffs": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", + "@t3tools/provider-copilot": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-fx": "workspace:*", diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index e23200653b963..8776af01bcfd1 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -11,6 +11,7 @@ import { modelShortName } from "@t3tools/provider-core/modelShortName"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { fxClient } from "@t3tools/provider-fx/client"; import { grokClient } from "@t3tools/provider-grok/client"; +import { gitHubCopilotClient } from "@t3tools/provider-copilot/client"; import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; @@ -42,6 +43,7 @@ export const providerClients = makeProviderClientRegistry([ acpRegistryAgentIds: ["antigravity-acp"], settingsSchema: AntigravitySettings, }, + gitHubCopilotClient, kiroClient, museClient, piClient, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..0cb3de31b6e13 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -120,6 +120,7 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); +const GITHUB_COPILOT_DRIVER_KIND = ProviderDriverKind.make("githubCopilot"); const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); @@ -150,6 +151,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [GROK_DRIVER_KIND]: "Grok", [FX_DRIVER_KIND]: "fx", [KIRO_DRIVER_KIND]: "Kiro", + [GITHUB_COPILOT_DRIVER_KIND]: "GitHub Copilot", [MUSE_DRIVER_KIND]: "Muse Code", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 461ec5e4b3f3e..40f5d09ffc925 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -924,6 +924,19 @@ export const OrchestrationV2Subagent = Schema.Struct({ }); export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type; +/** A strong task ref is how a provider says this task can be stopped without ending its run. */ +export function canStopNativeSubagent( + subagent: Pick, +): boolean { + return ( + subagent.origin === "provider_native" && + (subagent.driver === "claudeAgent" || subagent.driver === "githubCopilot") && + subagent.runId != null && + subagent.nativeTaskRef?.strength === "strong" && + subagent.nativeTaskRef.nativeId !== null + ); +} + export function isOrchestrationV2WorkActive( status: OrchestrationV2ExecutionNode["status"], ): boolean { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8a4b25589121c..00cac19137040 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1452,8 +1452,8 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => - ProviderDriverKind.make(driver), + ["cursor", "grok", "fx", "githubCopilot", "kiro", "muse", "pi", "opencode", "antigravity"].map( + (driver) => ProviderDriverKind.make(driver), ), ); diff --git a/packages/provider-acp-registry/package.json b/packages/provider-acp-registry/package.json index 84db307a4bf6e..e004a4560e7cc 100644 --- a/packages/provider-acp-registry/package.json +++ b/packages/provider-acp-registry/package.json @@ -26,6 +26,10 @@ "./server/AcpRegistryRuntimeCoordinator": { "types": "./src/server/AcpRegistryRuntimeCoordinator.ts", "import": "./src/server/AcpRegistryRuntimeCoordinator.ts" + }, + "./server/copilotAcp": { + "types": "./src/server/copilotAcp.ts", + "import": "./src/server/copilotAcp.ts" } }, "scripts": { diff --git a/packages/provider-acp-registry/src/server/copilotAcp.ts b/packages/provider-acp-registry/src/server/copilotAcp.ts index 5f29c930e9a91..2c3d631fcb379 100644 --- a/packages/provider-acp-registry/src/server/copilotAcp.ts +++ b/packages/provider-acp-registry/src/server/copilotAcp.ts @@ -114,7 +114,13 @@ export function makeCopilotSubagentRouting() { return context.finishSubagent({ sessionId: event.sessionId, childSessionId: childSessionId(data.toolCallId), - status: event.type === "subagent.failed" ? "failed" : "completed", + // Copilot reports a stopped agent as completed, marked cancelled. + status: + event.type === "subagent.failed" + ? "failed" + : data.cancelled === true + ? "cancelled" + : "completed", result: typeof data.error === "string" ? data.error : null, }); } diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index f10be3c952dc8..51740d768f263 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -137,6 +137,7 @@ import { subagentThreadTitle, } from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; @@ -440,6 +441,37 @@ export interface AcpAdapterV2Flavor { ) => Effect.Effect; readonly subagentsIdleOnTurnCompletion?: boolean; readonly supportsCompaction?: boolean; + /** Reads a finished plan from a live update, for an agent that ends planning inside its turn. */ + readonly proposedPlanMarkdown?: ( + notification: EffectAcpSchema.SessionNotification, + ) => string | undefined; + /** Reads the UI document a finished MCP tool returned, for T3 to snapshot and render. */ + readonly mcpAppDocument?: (toolCall: AcpToolCallState) => + | { + readonly resourceUri: string; + readonly contents: ReadonlyArray; + readonly result: unknown; + } + | undefined; + /** Serves a rendered MCP App's own tool and resource calls through the agent's session. */ + readonly mcpApps?: ( + runtime: () => AcpSessionRuntime.AcpSessionRuntime["Service"], + ) => ProviderAdapter.ProviderAdapterV2McpApps; + /** Lets a rejection carry the user's reason, delivered in the response's `_meta.feedback`. */ + readonly approvalFeedback?: boolean; + /** Rewinds a session to just after a turn, keeping its conversation; a failure starts afresh. */ + readonly rollbackSession?: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + nativeTurnId: string, + ) => Effect.Effect; + /** Stops one background task while the turn that started it keeps running. */ + readonly stopBackgroundTask?: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + taskId: string, + ) => Effect.Effect; + /** Marks the end of an assistant message, for an agent whose turn outlives its replies. */ + readonly closesAssistantMessage?: (notification: EffectAcpSchema.SessionNotification) => boolean; /** * Native steering: `send` queues a message into the running turn and returns the * agent's id for it, which `injectedSteerId` reads from the update that delivers it. @@ -449,7 +481,10 @@ export interface AcpAdapterV2Flavor { runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], sessionId: string, text: string, + images: ReadonlyArray<{ readonly data: string; readonly mimeType: string }>, ) => Effect.Effect; + /** Without this, a steer with attachments waits for the next turn. */ + readonly acceptsAttachments?: boolean; readonly injectedSteerId: ( notification: EffectAcpSchema.SessionNotification, ) => string | undefined; @@ -1894,6 +1929,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const activeTurn = yield* Ref.make(null); // Keyed by the agent's steer id; an injection can arrive before its send returns. const steerInjections = new Map>(); + const approvalFeedback = new Map(); const steerInjection = (steerId: string) => { const existing = steerInjections.get(steerId); if (existing !== undefined) return existing; @@ -1982,6 +2018,25 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( loadingMessageId: null, loadingIndex: 0, }); + const snapshotReloadRequired = yield* Ref.make(false); + // Replayed history for the active session must not read as post-turn output. + const snapshotReloading = yield* Ref.make(false); + const resetConversationCaches = Effect.all( + [ + Ref.set(promptInstructionStates, new Map()), + Ref.set(compactionMarkers, new Map()), + Ref.set(providerTurns, new Map()), + Ref.set(snapshot, { + order: [], + messages: new Map(), + loadingRole: null, + loadingMessageId: null, + loadingIndex: 0, + }), + Ref.set(snapshotReloadRequired, false), + ], + { discard: true }, + ); const awaitRuntimeTeardown = Effect.fnUntraced(function* () { const state = yield* Ref.get(runtimeTeardownState); @@ -2616,7 +2671,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runId: context.input.runId, nodeId, kind: "proposed_plan", - status: "completed", + status: "active", markdown, }, }); @@ -4085,6 +4140,20 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const input = mcpIdentity.input ?? unknownRecord(rawInputRecord?.arguments) ?? rawInputRecord ?? {}; let output = rawOutput === undefined ? undefined : acpMcpToolCallOutput(rawOutput); + const appDocument = + status === "completed" ? flavor.mcpAppDocument?.(toolCall) : undefined; + if (appDocument !== undefined && host.snapshotMcpApp !== undefined) { + const reference = yield* host.snapshotMcpApp({ + threadId: context.input.threadId, + server: mcpIdentity.server, + tool: mcpIdentity.tool, + resourceUri: appDocument.resourceUri, + contents: appDocument.contents, + }); + if (reference !== undefined) { + output = { [MCP_APP_OUTPUT_KEY]: reference, result: appDocument.result }; + } + } const isHtmlRender = resolveT3McpToolId(toolName) === HTML_RENDER_TOOL_NAME; const isError = compactDynamicToolOutput(output)?.isError === true || @@ -4690,7 +4759,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; } const rootSessionId = yield* Ref.get(activeSessionId); - if (rootSessionId === null || notification.sessionId !== rootSessionId) { + if ( + rootSessionId === null || + notification.sessionId !== rootSessionId || + (yield* Ref.get(snapshotReloading)) + ) { return { buffered: false, offerContinuation: false, @@ -5009,6 +5082,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if ( followsAgentWorkState && !(yield* Ref.get(stoppedRunQuarantine)) && + !(yield* Ref.get(snapshotReloading)) && notification.sessionId === (yield* Ref.get(activeSessionId)) ) { if (update.sessionUpdate === "state_update") agentWork = update; @@ -5576,6 +5650,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( responseCapability: { type: "live", providerSessionId: input.providerSessionId, + ...(flavor.approvalFeedback === true ? { supportsFeedback: true } : {}), }, createdAt: now, resolvedAt: null, @@ -6258,6 +6333,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (injectedSteerId !== undefined) { yield* Deferred.succeed(steerInjection(injectedSteerId), undefined); } + const proposedPlan = flavor.proposedPlanMarkdown?.(rawNotification); + if (proposedPlan !== undefined) { + yield* captureProposedPlan({ planMarkdown: proposedPlan }); + } + if (flavor.closesAssistantMessage?.(rawNotification) === true) { + const context = yield* Ref.get(activeTurn); + if (context !== null && !context.finalized) { + yield* closeTextStream(context, "assistant"); + } + } yield* markPromptStarted(rawNotification); const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); if (embedded !== undefined) { @@ -6593,6 +6678,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( turnKey: String(context.providerTurnId), }); } + const feedback = approvalFeedback.get(String(requestId)); + approvalFeedback.delete(String(requestId)); const response = (() => { if (decision === "cancel") { return { outcome: { outcome: "cancelled" } } as const; @@ -6600,7 +6687,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const optionId = selectPermissionOptionId(params, decision); return optionId === undefined ? ({ outcome: { outcome: "cancelled" } } as const) - : ({ outcome: { outcome: "selected", optionId } } as const); + : ({ + outcome: { outcome: "selected", optionId }, + ...(feedback === undefined ? {} : { _meta: { feedback } }), + } as const); })(); const checked = yield* runRuntimeCallbackAtGeneration( handlerGeneration, @@ -7071,16 +7161,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* setActiveSessionSetup(candidate); yield* Ref.set(activeSelection, null); yield* Ref.set(activeInteractionMode, null); - yield* Ref.set(promptInstructionStates, new Map()); - yield* Ref.set(compactionMarkers, new Map()); - yield* Ref.set(providerTurns, new Map()); - yield* Ref.set(snapshot, { - order: [], - messages: new Map(), - loadingRole: null, - loadingMessageId: null, - loadingIndex: 0, - }); + yield* resetConversationCaches; yield* continuationPermit.withPermit( Effect.gen(function* () { yield* Ref.update(continuationGeneration, (value) => value + 1); @@ -7873,6 +7954,33 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); + const readImageAttachment = Effect.fnUntraced(function* ( + attachment: ProviderAdapter.ProviderAdapterV2TurnMessage["attachments"][number], + ) { + const path = host.resolveAttachmentPath(attachment as ChatAttachment); + if (path === null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Invalid attachment id '${attachment.id}'`, + }); + } + const bytes = yield* fileSystem.readFile(path).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Failed to read attachment '${attachment.id}'`, + reason: "attachment_unreadable", + payload: cause, + }), + ), + ); + return { + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }; + }); + const resolvePromptParts = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, sessionId: string, @@ -7957,29 +8065,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( prompt.splice(0, prompt.length, ...blocks); } for (const attachment of flavor.buildPrompt ? [] : imageAttachments) { - const path = host.resolveAttachmentPath(attachment as ChatAttachment); - if (path === null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Invalid attachment id '${attachment.id}'`, - }); - } - const bytes = yield* fileSystem.readFile(path).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: `Failed to read attachment '${attachment.id}'`, - reason: "attachment_unreadable", - payload: cause, - }), - ), - ); - prompt.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); + prompt.push({ type: "image", ...(yield* readImageAttachment(attachment)) }); } if (prompt.length === 0) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -8753,11 +8839,29 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), } : {}), + ...(flavor.mcpApps === undefined ? {} : { mcpApps: flavor.mcpApps(() => runtime) }), + ...(flavor.stopBackgroundTask === undefined + ? {} + : { + stopSubagent: (stopInput: { readonly nativeTaskId: string }) => + flavor.stopBackgroundTask!(runtime, stopInput.nativeTaskId).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: `Could not stop task ${stopInput.nativeTaskId}`, + cause, + }), + ), + ), + }), steerTurn: Effect.fn("AcpAdapterV2.steerTurn")( function* (turnInput) { const steering = flavor.steering; - // The agent takes text only, so a steer with attachments waits for the next turn. - if (steering === undefined || turnInput.message.attachments.length > 0) { + if ( + steering === undefined || + (turnInput.message.attachments.length > 0 && steering.acceptsAttachments !== true) + ) { return yield* new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ driver, providerThreadId: turnInput.providerThread.id, @@ -8781,10 +8885,20 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.timeoutOption(startHold.bound), ); } + const images = yield* Effect.forEach( + turnInput.message.attachments.filter(isProviderNativeImageAttachment), + readImageAttachment, + ); const steerId = yield* steering.send( runtime, context.nativeThreadId, - turnInput.message.text, + yield* providerMessageTextWithAttachmentPaths({ + text: turnInput.message.text, + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + resolveExtractedTextPath: host.resolveExtractedTextPath, + }), + images, ); const injected = steerInjection(steerId); // The agent reads a steer at its next step. One that lands after the last @@ -9165,7 +9279,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( driver, detail: `ACP approval request ${requestInput.requestId} requires a decision`, }) - : yield* Deferred.succeed(pending.decision, requestInput.decision); + : yield* Effect.suspend(() => { + if (requestInput.feedback !== undefined) { + approvalFeedback.set( + String(requestInput.requestId), + requestInput.feedback, + ); + } + return Deferred.succeed(pending.decision, requestInput.decision!); + }); if (!settled) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, @@ -9198,7 +9320,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( snapshotInput.providerThread.appThreadId, ); const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread); - if ((yield* Ref.get(activeSessionId)) !== sessionId) { + const reload = + (yield* Ref.get(snapshotReloadRequired)) && + capabilities.threads.canReadThreadSnapshot && + (yield* Ref.get(activeTurn)) === null; + if ((yield* Ref.get(activeSessionId)) !== sessionId || reload) { if (!capabilities.threads.canReadThreadSnapshot) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, @@ -9216,10 +9342,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( snapshotInput.providerThread.appThreadId, ); prepareTerminalEnvironment(mcpContext, sessionId); - const activated = yield* runtime.loadSession( - sessionId, - acpMcpActivation(mcpContext), - ); + yield* Ref.set(snapshotReloading, reload); + const activated = yield* runtime + .loadSession(sessionId, acpMcpActivation(mcpContext)) + .pipe(Effect.ensuring(Ref.set(snapshotReloading, false))); + yield* Ref.set(snapshotReloadRequired, false); rememberTerminalEnvironment( activated.sessionId, yield* readMcpContext(snapshotInput.providerThread.appThreadId), @@ -9277,6 +9404,57 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( prepareTerminalEnvironment( yield* readMcpContext(rollbackInput.providerThread.appThreadId), ); + const rewoundTurn = + rollbackInput.target.type === "provider_turn" + ? rollbackInput.target.providerTurn.nativeTurnRef + : null; + const rollbackSession = flavor.rollbackSession; + const targetSession = rollbackInput.providerThread.nativeThreadRef?.nativeId; + if ( + rollbackSession !== undefined && + rewoundTurn?.strength === "strong" && + rewoundTurn.nativeId !== null && + targetSession !== null && + targetSession !== undefined + ) { + const turnId = rewoundTurn.nativeId; + const rewound = yield* Effect.gen(function* () { + // A fork or a failed load can leave a different session active. + if ((yield* Ref.get(activeSessionId)) !== targetSession) { + const activated = yield* activateSession( + targetSession, + rollbackInput.providerThread.appThreadId, + ); + yield* Ref.set(activeSessionId, activated.sessionId); + yield* setActiveSessionSetup(activated); + yield* Ref.set(activeSelection, null); + yield* Ref.set(activeInteractionMode, null); + } + yield* rollbackSession(runtime, targetSession, turnId); + }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("ACP session rewind failed; starting afresh", { + driver, + reason: error._tag, + }).pipe(Effect.as(false)), + ), + ); + if (rewound) { + yield* resetConversationCaches; + yield* Ref.set(snapshotReloadRequired, true); + return { + providerThread: { + ...rollbackInput.providerThread, + status: "idle" as const, + updatedAt: yield* DateTime.now, + }, + providerTurns: [], + messages: [], + runtimeRequests: [], + }; + } + } const replacement = yield* startReplacementAcpRuntime( rollbackInput.providerThread.appThreadId, (candidate) => @@ -9362,6 +9540,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* setActiveSessionSetup(forked); yield* Ref.set(activeSelection, null); yield* Ref.set(activeInteractionMode, null); + yield* Ref.set(snapshotReloadRequired, true); itemIdentityVersion = 2; const now = yield* DateTime.now; const providerThread = makeProviderThread({ diff --git a/packages/provider-copilot/package.json b/packages/provider-copilot/package.json new file mode 100644 index 0000000000000..428c140b62c8a --- /dev/null +++ b/packages/provider-copilot/package.json @@ -0,0 +1,43 @@ +{ + "name": "@t3tools/provider-copilot", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@github/copilot-sdk": "1.0.28", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-acp-registry": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-copilot/src/client.ts b/packages/provider-copilot/src/client.ts new file mode 100644 index 0000000000000..7d7df3110b55d --- /dev/null +++ b/packages/provider-copilot/src/client.ts @@ -0,0 +1,20 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { GitHubCopilotSettings } from "./settings.ts"; + +const MARK = + "M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z"; + +export const gitHubCopilotClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("githubCopilot"), + label: "GitHub Copilot", + featured: false, + badgeLabel: "Early Access", + settingsSchema: GitHubCopilotSettings, + icon: { + viewBox: "0 0 16 16", + fill: { light: "#000000", dark: "#FFFFFF" }, + paths: [{ d: MARK, fillRule: "evenodd" }], + }, +}); diff --git a/packages/provider-copilot/src/server.ts b/packages/provider-copilot/src/server.ts new file mode 100644 index 0000000000000..9ad340c2b204f --- /dev/null +++ b/packages/provider-copilot/src/server.ts @@ -0,0 +1 @@ +export { GitHubCopilotDriver, type GitHubCopilotDriverEnv } from "./server/driver.ts"; diff --git a/packages/provider-copilot/src/server/adapter.ts b/packages/provider-copilot/src/server/adapter.ts new file mode 100644 index 0000000000000..615dae314888c --- /dev/null +++ b/packages/provider-copilot/src/server/adapter.ts @@ -0,0 +1,250 @@ +import * as SecureRandom from "effect/crypto/SecureRandom"; +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 * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; + +import { ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2Flavor, + type AcpAdapterV2RuntimeInput, +} from "@t3tools/provider-acp/server/adapter"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; +import { + copilotClientCapabilitiesMeta, + extractCopilotSubagentUpdate, + isCopilotLostSessionError, + makeCopilotSubagentRouting, +} from "@t3tools/provider-acp-registry/server/copilotAcp"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import type * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readCustomModelEntries } from "@t3tools/shared/model"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveCommandPath } from "@t3tools/shared/shell"; + +import { GitHubCopilotSettings } from "../settings.ts"; +import type { CopilotSdkMcpAppRequest } from "./copilotSdkRuntime.ts"; +import { + compactCopilotSdkSession, + copilotSdkClosesAssistantMessage, + copilotSdkForkFromTurn, + copilotSdkMcpAppDocument, + copilotSdkProposedPlan, + copilotSdkSteering, + copilotLaunchCommand, + makeCopilotSdkClient, + makeCopilotSdkRuntime, + requestCopilotSdkMcpApp, + rollbackCopilotSdkSession, + stopCopilotSdkTask, +} from "./copilotSdkRuntime.ts"; + +export const GITHUB_COPILOT_PROVIDER = ProviderDriverKind.make("githubCopilot"); + +const DEFAULT_SETTINGS = Schema.decodeSync(GitHubCopilotSettings)({}); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); + +const GitHubCopilotProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + sessions: { ...AcpProviderCapabilitiesV2.sessions, supportsModelSwitchInSession: true }, + planning: { ...AcpProviderCapabilitiesV2.planning, emitsProposedPlan: true }, + subagents: { + ...AcpProviderCapabilitiesV2.subagents, + supportsSubagents: true, + emitsSubagentLifecycle: true, + }, + tools: { ...AcpProviderCapabilitiesV2.tools, supportsMcpTools: true }, + // An interrupt ends the main turn's running tool but leaves its background agents alive. + turns: { + ...AcpProviderCapabilitiesV2.turns, + supportsActiveSteering: true, + activeSteeringInterruptsTools: true, + supportsSteeringByInterruptRestart: false, + }, +}; + +function makeCopilotSdkMcpApps( + runtime: () => AcpSessionRuntime.AcpSessionRuntime["Service"], +): ProviderAdapter.ProviderAdapterV2McpApps { + const request = (payload: CopilotSdkMcpAppRequest) => + requestCopilotSdkMcpApp(runtime(), payload).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: GITHUB_COPILOT_PROVIDER, + detail: `Copilot MCP App request failed: ${cause.message}`, + }), + ), + ); + return { + listTools: ({ server }) => + request({ operation: "listTools", server }).pipe( + Effect.map((tools) => tools as ReadonlyArray), + ), + callTool: ({ server, tool, arguments: toolArguments }) => + request({ operation: "callTool", server, tool, arguments: toolArguments }).pipe( + Effect.map((result) => result as ProviderAdapter.ProviderAdapterV2McpCallToolResult), + ), + readResource: ({ server, uri }) => + request({ operation: "readResource", server, uri }).pipe( + Effect.map((result) => result as { readonly contents: ReadonlyArray }), + ), + }; +} + +function gitHubCopilotPromptFailure(cause: unknown) { + return makeProviderFailure({ + cause, + ...(isAcpRequestError(cause) ? { message: cause.errorMessage, code: String(cause.code) } : {}), + class: "provider_error", + }); +} + +export interface GitHubCopilotAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: GitHubCopilotSettings; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly selfInvocation: SelfInvocation; + readonly continuationRequests?: Parameters[0]["continuationRequests"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: AcpAdapterV2Flavor["makeRuntime"]; +} + +function makeFlavor(options: GitHubCopilotAdapterV2Options): AcpAdapterV2Flavor { + const routing = makeCopilotSubagentRouting(); + return { + driver: GITHUB_COPILOT_PROVIDER, + runtimeHarness: "GitHub Copilot", + capabilities: GitHubCopilotProviderCapabilitiesV2, + customModels: readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug), + clientCapabilitiesMeta: copilotClientCapabilitiesMeta, + mcpOverHttp: true, + promptFailure: gitHubCopilotPromptFailure, + registerExtensions: routing.registerExtensions, + normalizeSessionUpdate: routing.normalizeSessionUpdate, + extractSubagentUpdate: extractCopilotSubagentUpdate, + restartRuntimeAfterPromptError: isCopilotLostSessionError, + reportsPerTurnPromptUsage: true, + steering: copilotSdkSteering, + closesAssistantMessage: copilotSdkClosesAssistantMessage, + compactSession: compactCopilotSdkSession, + forkFromTurn: copilotSdkForkFromTurn, + proposedPlanMarkdown: copilotSdkProposedPlan, + stopBackgroundTask: stopCopilotSdkTask, + approvalFeedback: true, + mcpAppDocument: (toolCall) => + copilotSdkMcpAppDocument(toolCall.data.meta, toolCall.data.rawOutput), + mcpApps: makeCopilotSdkMcpApps, + rollbackSession: rollbackCopilotSdkSession, + makeRuntime: + options.makeRuntime ?? + Effect.fnUntraced(function* (input: AcpAdapterV2RuntimeInput) { + const { processEnvironment, ...runtimeInput } = input; + const env = { ...options.environment, ...processEnvironment }; + const binary = options.settings.binaryPath || "copilot"; + // The SDK only checks that the file exists, so it never searches PATH itself. + const command = yield* resolveCommandPath(binary, { env }).pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + ); + if (command === null) { + return yield* new AcpErrors.AcpSpawnError({ command: binary, cause: undefined }); + } + return yield* makeCopilotSdkRuntime({ + ...runtimeInput, + client: makeCopilotSdkClient({ + command: yield* copilotLaunchCommand(command), + args: [], + env, + }), + }); + }), + }; +} + +export const makeGitHubCopilotAdapterV2 = Effect.fn("makeGitHubCopilotAdapterV2")(function* ( + options: GitHubCopilotAdapterV2Options, +) { + return yield* makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: makeFlavor(options), + selfInvocation: options.selfInvocation, + ...(options.continuationRequests === undefined + ? {} + : { continuationRequests: options.continuationRequests }), + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +}); + +export type GitHubCopilotAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | FileSystem.FileSystem + | IdAllocator.IdAllocatorV2 + | Path.Path + | McpProviderSessions.McpProviderSessions + | ProviderEventLoggers.ProviderEventLoggers + | ProviderHost.ProviderHost; + +export const GitHubCopilotAdapterV2Driver: ProviderAdapterDriver< + GitHubCopilotSettings, + GitHubCopilotAdapterV2DriverEnv +> = { + driverKind: GITHUB_COPILOT_PROVIDER, + configSchema: GitHubCopilotSettings, + defaultConfig: (): GitHubCopilotSettings => DEFAULT_SETTINGS, + create: Effect.fn("GitHubCopilotAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcess.Environment; + const selfInvocation = yield* resolveSelfInvocation(); + const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return yield* makeGitHubCopilotAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + selfInvocation, + continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: GITHUB_COPILOT_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: GITHUB_COPILOT_PROVIDER, + instanceId: input.instanceId, + detail: "Failed to create the GitHub Copilot adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/packages/provider-copilot/src/server/copilotSdkRuntime.test.ts b/packages/provider-copilot/src/server/copilotSdkRuntime.test.ts new file mode 100644 index 0000000000000..b867ab2dc18e9 --- /dev/null +++ b/packages/provider-copilot/src/server/copilotSdkRuntime.test.ts @@ -0,0 +1,1112 @@ +import type { + CopilotClient, + CopilotSession, + ModelInfo, + PermissionRequest, + SessionEvent, +} from "@github/copilot-sdk"; +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 TestClock from "effect/testing/TestClock"; +import type * as AcpSchema from "effect-acp/compat"; +import * as AcpErrors from "effect-acp/errors"; + +import type { AcpAdapterV2ExtensionContext } from "@t3tools/provider-acp/server/adapter"; + +import { + isCopilotLostSessionError, + makeCopilotSubagentRouting, +} from "@t3tools/provider-acp-registry/server/copilotAcp"; +import { + COPILOT_SDK_COMPACT_METHOD, + COPILOT_SDK_ROLLBACK_METHOD, + COPILOT_SDK_STEER_METHOD, + COPILOT_SDK_STOP_TASK_METHOD, + copilotInvestigationDecision, + copilotInvestigationToolDecision, + copilotPermissionDecision, + copilotSdkClosesAssistantMessage, + copilotSdkForkBoundary, + copilotSdkForkFromTurn, + copilotSdkHistoryMessage, + copilotSdkInjectedSteerId, + copilotSdkMcpAppDocument, + copilotSdkMcpServers, + copilotSdkProposedPlan, + copilotSlashCommand, + copilotWindowsExecutable, + generateCopilotText, + makeCopilotSdkEventTranslator, + makeCopilotSdkRuntime, + type CopilotSdkClient, + type CopilotSdkCommandResult, + type CopilotSdkSession, +} from "./copilotSdkRuntime.ts"; + +const event = (type: string, data: unknown = {}, agentId?: string) => + ({ type, data, ...(agentId === undefined ? {} : { agentId }) }) as unknown as SessionEvent; + +function makeFakes() { + const calls: Array = []; + const sent: Array = []; + let listener: (event: SessionEvent) => void = () => undefined; + let pingFailure: Error | undefined; + let pingHangs = false; + let sourceHistory: ReadonlyArray = []; + let compactSucceeds = true; + let history: ReadonlyArray = []; + let sessionConfig: Parameters[0] | undefined; + let commandResult: CopilotSdkCommandResult = { kind: "text", text: "Session Usage" }; + let models: ReadonlyArray = []; + let currentEffort: string | undefined = "medium"; + const session: CopilotSdkSession = { + sessionId: "session-1", + on: (handler) => { + listener = handler; + return () => undefined; + }, + send: async ({ prompt, attachments }) => { + calls.push(`send:${prompt}`); + if (attachments !== undefined) sent.push(attachments); + return `message:${prompt}`; + }, + abort: async () => { + calls.push("abort"); + }, + interruptMainTurn: async (flushQueued) => { + calls.push(`interrupt:${flushQueued}`); + }, + currentModel: async () => ({ + modelId: "model-a", + ...(currentEffort === undefined ? {} : { reasoningEffort: currentEffort }), + }), + switchModel: async () => undefined, + setReasoningEffort: async (reasoningEffort) => { + calls.push(`effort:${reasoningEffort}`); + }, + setMode: async () => undefined, + setApproveAll: async () => undefined, + history: async () => history, + disconnect: async () => undefined, + truncate: async (eventId) => { + calls.push(`truncate:${eventId}`); + }, + compact: async () => { + calls.push("compact"); + return { success: compactSucceeds }; + }, + commands: async () => [{ name: "usage", description: "Show usage" }], + invokeCommand: async (name, input) => { + calls.push(`invoke:${name}:${input ?? ""}`); + return commandResult; + }, + mcpAppTools: async () => [], + callMcpAppTool: async () => ({}), + readMcpAppResource: async () => ({}), + cancelTask: async (toolCallId) => { + calls.push(`cancel:${toolCallId}`); + return toolCallId === "task-1"; + }, + }; + const client: CopilotSdkClient = { + start: async () => undefined, + ping: async () => { + if (pingFailure !== undefined) throw pingFailure; + if (pingHangs) await new Promise(() => undefined); + }, + authStatus: async () => ({ isAuthenticated: true }), + serverCommands: async () => [], + listModels: async () => models, + createSession: async (config) => { + sessionConfig = config; + return session; + }, + resumeSession: async () => session, + sessionHistory: async (sessionId) => { + calls.push(`sessionHistory:${sessionId}`); + return sourceHistory; + }, + generateText: async () => "", + fork: async (sessionId, toEventId) => { + calls.push(`fork:${sessionId}:${toEventId ?? "head"}`); + return "session-2"; + }, + stop: async () => undefined, + forceStop: async () => undefined, + }; + return { + calls, + client, + emit: (next: SessionEvent) => listener(next), + failPing: (error: Error) => { + pingFailure = error; + }, + hangPing: () => { + pingHangs = true; + }, + setSourceHistory: (events: ReadonlyArray) => { + sourceHistory = events; + }, + sessionConfig: () => sessionConfig!, + sentAttachments: () => sent, + setHistory: (events: ReadonlyArray) => { + history = events; + }, + failCompact: () => { + compactSucceeds = false; + }, + setCommandResult: (result: CopilotSdkCommandResult) => { + commandResult = result; + }, + setModels: (next: ReadonlyArray, effort: string | undefined) => { + models = next; + currentEffort = effort; + }, + }; +} + +const startPrompt = Effect.fn("startPrompt")(function* ( + runtime: Effect.Success>, +) { + yield* runtime.start(); + const dispatched = yield* Deferred.make(); + const fiber = yield* runtime + .prompt({ prompt: [{ type: "text", text: "go" }] }, { dispatched }) + .pipe(Effect.forkChild); + yield* Deferred.await(dispatched); + return fiber; +}); + +it("translates tool events into the ACP updates Copilot's native layer sends", () => { + const translate = makeCopilotSdkEventTranslator(); + const path = "/repo/f1.ts"; + assert.deepEqual( + translate( + event("tool.execution_start", { + toolCallId: "edit-1", + toolName: "edit", + toolTitle: "Editing file", + arguments: { path, old_str: "a", new_str: "b" }, + }), + ), + [ + { + sessionUpdate: "tool_call", + toolCallId: "edit-1", + title: "Editing file", + kind: "edit", + status: "pending", + rawInput: { path, old_str: "a", new_str: "b" }, + locations: [{ path }], + }, + ], + ); + assert.deepEqual( + translate( + event("tool.execution_complete", { + toolCallId: "edit-1", + success: true, + result: { content: "updated" }, + }), + ), + [ + { + sessionUpdate: "tool_call_update", + toolCallId: "edit-1", + status: "completed", + content: [{ type: "diff", path, oldText: "a", newText: "b" }], + rawOutput: { content: "updated" }, + }, + ], + ); + assert.deepEqual( + translate( + event( + "tool.execution_start", + { + toolCallId: "bash-1", + toolName: "bash", + arguments: { command: "echo hi", description: "Echo hi" }, + }, + "agent-a", + ), + ), + [ + { + sessionUpdate: "tool_call", + toolCallId: "bash-1", + title: "Echo hi", + kind: "execute", + status: "pending", + rawInput: { command: "echo hi", description: "Echo hi" }, + _meta: { "github.com/copilot": { agentId: "agent-a" } }, + }, + ], + ); + assert.deepEqual( + translate( + event("tool.execution_complete", { + toolCallId: "bash-1", + success: false, + error: { message: "boom" }, + }), + ), + [ + { + sessionUpdate: "tool_call_update", + toolCallId: "bash-1", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "boom" } }], + rawOutput: { message: "boom" }, + }, + ], + ); +}); + +it("fails the main agent's running tools on abort and leaves agent tools alone", () => { + const translate = makeCopilotSdkEventTranslator(); + translate(event("tool.execution_start", { toolCallId: "main", toolName: "bash" })); + translate(event("tool.execution_start", { toolCallId: "done", toolName: "bash" })); + translate(event("tool.execution_complete", { toolCallId: "done", success: true })); + translate(event("tool.execution_start", { toolCallId: "child", toolName: "bash" }, "agent-a")); + translate( + event("tool.execution_start", { + toolCallId: "nested", + toolName: "bash", + parentToolCallId: "task-1", + }), + ); + + assert.deepEqual(translate(event("abort", { reason: "user_initiated" })), [ + { sessionUpdate: "tool_call_update", toolCallId: "main", status: "failed" }, + ]); + assert.deepEqual(translate(event("abort", { reason: "user_initiated" })), []); +}); + +// regression: a reply stayed hidden until a background agent let the turn end +it("closes the text segment only when the main agent finishes a message", () => { + const translate = makeCopilotSdkEventTranslator(); + const closes = (data: unknown) => + translate(event("assistant.message", data)).some((update) => + copilotSdkClosesAssistantMessage({ sessionId: "s", update }), + ); + assert.isTrue(closes({ content: "reply" })); + assert.isFalse(closes({ content: "agent reply", parentToolCallId: "task-1" })); +}); + +it("surfaces a finished plan as a proposal", () => { + const [update] = makeCopilotSdkEventTranslator()( + event("exit_plan_mode.requested", { + requestId: "r1", + summary: "Add a file", + planContent: "# Plan", + actions: [], + recommendedAction: "interactive", + }), + ); + assert.strictEqual(copilotSdkProposedPlan({ sessionId: "s", update: update! }), "# Plan"); + const [ordinaryTool] = makeCopilotSdkEventTranslator()( + event("tool.execution_start", { + toolCallId: "t1", + toolName: "edit", + arguments: { planContent: "not a plan" }, + }), + ); + assert.isUndefined(copilotSdkProposedPlan({ sessionId: "s", update: ordinaryTool! })); +}); + +it("reads a slash command from behind T3's instruction prefix", () => { + assert.deepEqual(copilotSlashCommand("/usage"), { name: "usage", input: undefined }); + assert.deepEqual(copilotSlashCommand("/review the auth code"), { + name: "review", + input: "the auth code", + }); + assert.deepEqual( + copilotSlashCommand("\nrules\n\n\n/usage"), + { + name: "usage", + input: undefined, + }, + ); + assert.isUndefined(copilotSlashCommand("use /usage please")); + assert.isUndefined(copilotSlashCommand("/etc/hosts is a file")); +}); + +// `toEventId` is exclusive: stopping at the turn's own message would drop the turn from the fork. +it("forks a turn at the next user message, or at the head after the last turn", () => { + const history = [ + { id: "u1", type: "user.message" }, + { id: "a1", type: "assistant.message" }, + { id: "u2", type: "user.message" }, + { id: "a2", type: "assistant.message" }, + ] as ReadonlyArray>; + assert.strictEqual(copilotSdkForkBoundary(history, "u1"), "u2"); + assert.isUndefined(copilotSdkForkBoundary(history, "u2")); + const [update] = makeCopilotSdkEventTranslator()({ + ...event("user.message", { messageId: "m-1" }), + id: "u1", + } as SessionEvent); + assert.strictEqual( + copilotSdkForkFromTurn.nativeTurnId({ sessionId: "s", update: update! }), + "u1", + ); +}); + +it.effect("reports a stopped subagent as cancelled, not completed", () => + Effect.gen(function* () { + const finished: Array = []; + let onEvent: (payload: unknown) => Effect.Effect = () => Effect.void; + yield* makeCopilotSubagentRouting().registerExtensions({ + runtime: { + handleExtNotification: (_method: string, _schema: unknown, handler: typeof onEvent) => + Effect.sync(() => { + onEvent = handler; + }), + }, + finishSubagent: (notice: { readonly status: string }) => + Effect.sync(() => { + finished.push(notice.status); + }), + } as unknown as AcpAdapterV2ExtensionContext); + + const end = (data: Record) => + onEvent({ sessionId: "s", type: "subagent.completed", data: { toolCallId: "t", ...data } }); + yield* end({ cancelled: true }); + yield* end({}); + + assert.deepEqual(finished, ["cancelled", "completed"]); + }), +); + +// The document can reach 5 MiB, and tool output is stored on the turn item. +it("hands an MCP tool's UI document over without keeping it in the tool output", () => { + const translate = makeCopilotSdkEventTranslator(); + const started = translate( + event("tool.execution_start", { + toolCallId: "app-1", + toolName: "weather-show_weather", + mcpServerName: "weather", + mcpToolName: "show_weather", + arguments: { city: "Paris" }, + }), + )[0] as { readonly _meta?: unknown }; + assert.deepEqual(started._meta, { serverId: "weather", toolName: "show_weather" }); + + const uiResource = { uri: "ui://weather/card", mimeType: "text/html", text: "

Hi

" }; + const finished = translate( + event("tool.execution_complete", { + toolCallId: "app-1", + success: true, + result: { content: "Sunny", structuredContent: { sky: "sunny" }, uiResource }, + }), + )[0] as { readonly _meta?: unknown; readonly rawOutput?: unknown }; + + assert.deepEqual(finished.rawOutput, { content: "Sunny", structuredContent: { sky: "sunny" } }); + assert.deepEqual(copilotSdkMcpAppDocument(finished._meta, finished.rawOutput), { + resourceUri: "ui://weather/card", + contents: [uiResource], + result: { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { sky: "sunny" }, + }, + }); + assert.isUndefined(copilotSdkMcpAppDocument({}, finished.rawOutput)); +}); + +it("marks each user message so the adapter can confirm a steer landed", () => { + const [update] = makeCopilotSdkEventTranslator()(event("user.message", { messageId: "m-1" })); + assert.strictEqual( + copilotSdkInjectedSteerId({ sessionId: "s", update: update! } as AcpSchema.SessionNotification), + "m-1", + ); +}); + +it("keeps only remote MCP servers with their headers", () => { + assert.deepEqual( + copilotSdkMcpServers([ + { + type: "http", + name: "t3-code", + url: "http://127.0.0.1/mcp", + headers: [{ name: "Authorization", value: "Bearer token" }], + }, + { name: "stdio", command: "node", args: [], env: [] }, + ] as ReadonlyArray), + { + "t3-code": { + type: "http", + url: "http://127.0.0.1/mcp", + headers: { Authorization: "Bearer token" }, + tools: ["*"], + }, + }, + ); +}); + +it("lets an investigation run plain git reads and read the checkout, and nothing else", () => { + const shell = (fullCommandText: string, hasWriteFileRedirection = false) => + ({ kind: "shell", fullCommandText, hasWriteFileRedirection }) as PermissionRequest; + const read = (path: string) => ({ kind: "read", path }) as PermissionRequest; + const decide = (request: PermissionRequest) => + copilotInvestigationDecision(request, "/repo").kind; + + for (const allowed of [ + shell("git diff --stat abc123...HEAD"), + shell("git log --oneline --format=%H abc123..HEAD"), + shell("git diff abc123...HEAD -- src/a.ts"), + shell("git show HEAD:src/a.ts"), + read("/repo"), + read("/repo/src/a.ts"), + read("src/a.ts"), + ]) { + assert.strictEqual(decide(allowed), "approve-once"); + } + for (const refused of [ + shell("git diff --output=/tmp/x"), + shell("git diff --outp=/tmp/x"), + shell("git diff -o /tmp/x"), + shell("git diff --ext-diff"), + shell("git diff --no-index /etc/passwd /dev/null"), + shell("git diff /etc/passwd README.md"), + shell("git diff ../secret README.md"), + shell("git diff -- ../secret README.md"), + shell("git diff ~/secret README.md"), + shell("git diff C:/secret README.md"), + shell("git diff -O/etc/passwd"), + shell("git log --textconv"), + shell("git diff | sh"), + shell("git diff; rm -rf ."), + shell("git diff $(rm x)"), + shell("git diff > out", true), + shell("git checkout main"), + shell("git -c core.pager=sh diff"), + shell("rm -rf ."), + read("/etc/passwd"), + read("../secrets"), + read("/repo/../secrets"), + read("/repository/other"), + { kind: "write" } as PermissionRequest, + { kind: "url" } as PermissionRequest, + { kind: "mcp" } as PermissionRequest, + ]) { + assert.strictEqual(decide(refused), "reject"); + } +}); + +// Copilot skips the permission request for these, so the hook alone stops them. +it("refuses tool calls Copilot would run without asking", async () => { + const links: Record = { "/repo/out": "/etc/passwd" }; + const decide = async (toolName: string, toolArgs: unknown) => + ( + await copilotInvestigationToolDecision( + { toolName, toolArgs }, + "/repo", + async (path) => links[path] ?? path, + ) + ).permissionDecision; + + for (const [toolName, toolArgs, expected] of [ + ["bash", { command: "git log --oneline a..HEAD" }, "allow"], + ["view", { path: "/repo/a.ts" }, "allow"], + ["grep", { pattern: "x" }, "allow"], + ["grep", { pattern: "x", paths: "src", output_mode: "content", "-n": true }, "allow"], + ["grep", { pattern: "../not-a-path", glob: "**/*.ts" }, "allow"], + ["glob", { pattern: "src/**/*.ts" }, "allow"], + ["bash", { command: "git diff --output=leak.txt" }, "deny"], + ["bash", { command: "git diff ../secret a.ts" }, "deny"], + ["bash", { command: "ls" }, "deny"], + ["bash", {}, "deny"], + ["grep", { pattern: "x", path: "/etc" }, "deny"], + ["grep", { pattern: "x", glob: "../**" }, "deny"], + ["glob", { pattern: "/etc/*" }, "deny"], + ["glob", { pattern: "{a,/etc}/*" }, "deny"], + ["glob", { pattern: "../*" }, "deny"], + ["view", { path: "/repo/../secrets" }, "deny"], + ["view", { path: "/repo/out" }, "deny"], + ["grep", { pattern: "x", paths: "out" }, "deny"], + ["grep", { pattern: "x", paths: ["src", "out"] }, "deny"], + ["grep", { pattern: "x", somethingNew: "/etc" }, "deny"], + ["edit", { path: "/repo/a.ts" }, "deny"], + ] as const) { + assert.strictEqual(await decide(toolName, toolArgs), expected, JSON.stringify(toolArgs)); + } +}); + +it("swaps npm's Windows shim for the binary behind it", () => { + const root = "C:\\npm\\prefix"; + const nested = `${root}\\node_modules\\@github\\copilot\\node_modules\\@github\\copilot-win32-x64\\copilot.exe`; + const hoisted = `${root}\\node_modules\\@github\\copilot-win32-x64\\copilot.exe`; + const resolve = (command: string, ...present: Array) => + copilotWindowsExecutable(command, "x64", (path) => present.includes(path)); + + assert.strictEqual(resolve(`${root}\\copilot.cmd`, nested), nested); + assert.strictEqual(resolve(`${root}\\copilot.CMD`, hoisted), hoisted); + assert.strictEqual(resolve(`${root}\\copilot.cmd`), `${root}\\copilot.cmd`); + assert.strictEqual(resolve("C:\\tools\\copilot.exe"), "C:\\tools\\copilot.exe"); +}); + +it("rejects a permission unless an allow option was selected", () => { + const selected = (optionId: string) => + copilotPermissionDecision({ outcome: { outcome: "selected", optionId } }); + assert.deepEqual(selected("allow_once"), { kind: "approve-once" }); + assert.deepEqual(selected("allow_always"), { kind: "approve-for-session" }); + assert.deepEqual(selected("reject_once"), { kind: "reject" }); + assert.deepEqual( + copilotPermissionDecision({ + outcome: { outcome: "selected", optionId: "reject_once" }, + _meta: { feedback: "use pnpm instead" }, + }), + { kind: "reject", feedback: "use pnpm instead" }, + ); + assert.deepEqual(copilotPermissionDecision({ outcome: { outcome: "cancelled" } }), { + kind: "reject", + }); +}); + +it.effect("steers a working main agent by queueing, then interrupting only its turn", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + fakes.emit(event("assistant.turn_start")); + + const steered = yield* runtime.request(COPILOT_SDK_STEER_METHOD, { text: "change course" }); + + assert.deepEqual(steered, { messageId: "message:change course" }); + assert.deepEqual(fakes.calls, ["send:go", "send:change course", "interrupt:true"]); + // The steer's own abort must not end the prompt: its follow-up turn is still to come. + fakes.emit(event("abort", { reason: "user_initiated" })); + fakes.emit(event("assistant.turn_start")); + fakes.emit(event("assistant.idle")); + fakes.emit(event("session.idle")); + assert.strictEqual((yield* Fiber.join(prompt)).stopReason, "end_turn"); + }).pipe(Effect.scoped), +); + +// regression: interrupting after the main agent finished loses the queued message +it.effect("applies a reasoning level when Copilot reports no current one", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setModels( + [{ id: "model-a", name: "A", supportedReasoningEfforts: ["low", "high"] } as ModelInfo], + undefined, + ); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + const options = yield* runtime.getConfigOptions; + assert.isTrue(options.some((option) => option.id === "reasoning_effort")); + yield* runtime.setConfigOption("reasoning_effort", "low"); + assert.include(fakes.calls, "effort:low"); + }).pipe(Effect.scoped), +); + +it.effect("sends without interrupting once the main agent has finished", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + fakes.emit(event("assistant.turn_start")); + fakes.emit(event("assistant.idle")); + + yield* runtime.request(COPILOT_SDK_STEER_METHOD, { text: "change course" }); + + assert.deepEqual(fakes.calls, ["send:go", "send:change course"]); + fakes.emit(event("session.idle")); + yield* Fiber.join(prompt); + }).pipe(Effect.scoped), +); + +it.effect("reports a real cancel after an earlier steer finished", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + fakes.emit(event("assistant.turn_start")); + yield* runtime.request(COPILOT_SDK_STEER_METHOD, { text: "change course" }); + fakes.emit(event("assistant.turn_start")); + + yield* runtime.cancel; + fakes.emit(event("session.idle", { aborted: true })); + + assert.strictEqual((yield* Fiber.join(prompt)).stopReason, "cancelled"); + }).pipe(Effect.scoped), +); + +it.effect("settles a turn only after its output reached the adapter", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const seen: Array = []; + yield* runtime.handleSessionUpdate((notification) => + Effect.yieldNow.pipe( + Effect.andThen( + Effect.sync(() => { + seen.push(notification.update.sessionUpdate); + }), + ), + ), + ); + const prompt = yield* startPrompt(runtime); + + fakes.emit(event("assistant.message_delta", { deltaContent: "last words" })); + fakes.emit(event("session.idle")); + yield* Fiber.join(prompt); + + assert.strictEqual(seen.at(-1), "agent_message_chunk"); + }).pipe(Effect.scoped), +); + +it.effect("fails the prompt with an error the lost-session restart recognises", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + + fakes.emit( + event("session.error", { + errorType: "internal", + message: "session event delivery failed: session not found: session-1", + }), + ); + + const failure = yield* Fiber.join(prompt).pipe(Effect.flip); + assert.isTrue(isCopilotLostSessionError(failure)); + }).pipe(Effect.scoped), +); + +it.effect("fails an open prompt and reports termination when the runtime dies", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const terminated = yield* Deferred.make(); + const runtime = yield* makeCopilotSdkRuntime({ + cwd: "/repo", + client: fakes.client, + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const prompt = yield* startPrompt(runtime); + + fakes.failPing(new Error("connection closed")); + yield* TestClock.adjust("10 seconds"); + + const failure = yield* Fiber.join(prompt).pipe(Effect.flip); + assert.strictEqual(failure, yield* Deferred.await(terminated)); + }).pipe(Effect.scoped), +); + +it.effect("reports termination when the runtime stops answering pings", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const terminated = yield* Deferred.make(); + const runtime = yield* makeCopilotSdkRuntime({ + cwd: "/repo", + client: fakes.client, + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const prompt = yield* startPrompt(runtime); + + fakes.hangPing(); + yield* TestClock.adjust("40 seconds"); + + const failure = yield* Fiber.join(prompt).pipe(Effect.flip); + assert.strictEqual(failure, yield* Deferred.await(terminated)); + }).pipe(Effect.scoped), +); + +it.effect("keeps the prompt open when only a sub-agent fails", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + + fakes.emit(event("session.error", { errorType: "query", message: "agent failed" }, "agent-1")); + fakes.emit(event("session.idle")); + + assert.strictEqual((yield* Fiber.join(prompt)).stopReason, "end_turn"); + }).pipe(Effect.scoped), +); + +it("replays complete messages from history, leaving out sub-agents", () => { + assert.deepEqual( + copilotSdkHistoryMessage( + event("user.message", { + content: "\nrules\n\n\nhi", + }), + ), + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "hi" } }, + ); + assert.deepEqual(copilotSdkHistoryMessage(event("user.message", { content: "hi" })), { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hi" }, + }); + assert.deepEqual( + copilotSdkHistoryMessage(event("assistant.message", { content: "yo", messageId: "m1" })), + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "yo" }, + messageId: "m1", + }, + ); + assert.deepEqual( + copilotSdkHistoryMessage(event("assistant.reasoning", { content: "hm", reasoningId: "r1" })), + { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "hm" }, + messageId: "r1", + }, + ); + for (const hidden of [ + event("assistant.message", { content: "", messageId: "m2" }), + event("assistant.message", { content: "x", messageId: "m3", parentToolCallId: "t" }), + event("user.message", { content: "task" }, "agent-1"), + ]) { + assert.isUndefined(copilotSdkHistoryMessage(hidden)); + } +}); + +it.effect("rebuilds messages when resuming a session", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setHistory([ + event("user.message", { content: "hi" }), + event("assistant.message", { content: "yo", messageId: "m1" }), + ]); + const runtime = yield* makeCopilotSdkRuntime({ + cwd: "/repo", + client: fakes.client, + resumeSessionId: "session-1", + }); + const seen: Array = []; + yield* runtime.handleSessionUpdate((notification) => + Effect.sync(() => { + seen.push(notification.update); + }), + ); + + yield* runtime.start(); + + assert.deepEqual( + seen.flatMap((update) => + update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk" + ? [update.sessionUpdate] + : [], + ), + ["user_message_chunk", "agent_message_chunk"], + ); + }).pipe(Effect.scoped), +); + +it.effect("forks another session using that session's own history", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setSourceHistory([ + { id: "u1", type: "user.message" } as SessionEvent, + { id: "u2", type: "user.message" } as SessionEvent, + ]); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + yield* runtime.forkSession("source", undefined, copilotSdkForkFromTurn.forkMeta("u1")); + + assert.deepEqual(fakes.calls, ["sessionHistory:source", "fork:source:u2"]); + }).pipe(Effect.scoped), +); + +it.effect("refuses to fork from a turn its session does not have", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + const failure = yield* runtime + .forkSession("source", undefined, copilotSdkForkFromTurn.forkMeta("gone")) + .pipe(Effect.flip); + + assert.include(failure.message, "gone"); + assert.deepEqual(fakes.calls, ["sessionHistory:source"]); + }).pipe(Effect.scoped), +); + +it("limits only an investigation to read-only tools behind the hook", async () => { + const configs: Array[0]> = []; + const client = { + start: async () => undefined, + createSession: async (config: (typeof configs)[number]) => { + configs.push(config); + return { + sessionId: "text", + sendAndWait: async () => undefined, + disconnect: async () => undefined, + } as unknown as CopilotSession; + }, + deleteSession: async () => undefined, + stop: async () => [], + }; + const input = { cwd: "/repo", model: "m", prompt: "p", timeoutMs: 1 }; + + await generateCopilotText(client, input); + await generateCopilotText(client, { ...input, investigate: true }); + + assert.deepEqual(configs[0]?.availableTools, []); + assert.isUndefined(configs[0]?.hooks); + assert.deepEqual(configs[1]?.availableTools, ["view", "grep", "glob", "bash"]); + assert.isDefined(configs[1]?.hooks?.onPreToolUse); +}); + +it("stops the client when a text generation session cannot start", async () => { + let stopped = false; + const generated = generateCopilotText( + { + start: async () => undefined, + createSession: async () => { + throw new Error("no session"); + }, + deleteSession: async () => undefined, + stop: async () => { + stopped = true; + return []; + }, + }, + { cwd: "/repo", model: "m", prompt: "p", timeoutMs: 1 }, + ); + + await generated.then( + () => assert.fail("expected generation to fail"), + (error) => assert.include(String(error), "no session"), + ); + assert.isTrue(stopped); +}); + +it.effect("runs a known slash command through invoke and shows its output", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const seen: Array = []; + yield* runtime.handleSessionUpdate((notification) => + Effect.sync(() => { + seen.push(notification.update); + }), + ); + yield* runtime.start(); + + const result = yield* runtime.prompt({ prompt: [{ type: "text", text: "/usage" }] }); + + assert.strictEqual(result.stopReason, "end_turn"); + assert.deepEqual(fakes.calls, ["invoke:usage:"]); + assert.deepEqual( + seen.map((update) => update.sessionUpdate), + ["available_commands_update", "agent_message_chunk"], + ); + }).pipe(Effect.scoped), +); + +it.effect("sends the prompt a slash command expands to", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setCommandResult({ kind: "agent-prompt", prompt: "expanded", displayPrompt: "/usage" }); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + const dispatched = yield* Deferred.make(); + const prompt = yield* runtime + .prompt( + { + prompt: [ + { type: "text", text: "/usage" }, + { type: "image", data: "aGk=", mimeType: "image/png" }, + ], + }, + { dispatched }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(dispatched); + fakes.emit(event("session.idle")); + yield* Fiber.join(prompt); + + assert.deepEqual(fakes.calls, ["invoke:usage:", "send:expanded"]); + assert.deepEqual(fakes.sentAttachments(), [ + [{ type: "blob", data: "aGk=", mimeType: "image/png" }], + ]); + }).pipe(Effect.scoped), +); + +it.effect("sends an unknown slash command to the model as text", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + const dispatched = yield* Deferred.make(); + const prompt = yield* runtime + .prompt({ prompt: [{ type: "text", text: "/not-a-command" }] }, { dispatched }) + .pipe(Effect.forkChild); + yield* Deferred.await(dispatched); + fakes.emit(event("session.idle")); + yield* Fiber.join(prompt); + + assert.deepEqual(fakes.calls, ["send:/not-a-command"]); + }).pipe(Effect.scoped), +); + +it.effect("reports a failed compaction", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.failCompact(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + const failure = yield* runtime.request(COPILOT_SDK_COMPACT_METHOD, {}).pipe( + Effect.as(""), + Effect.catch((error) => Effect.succeed(error.message)), + ); + + assert.include(failure, "could not compact"); + }).pipe(Effect.scoped), +); + +it.effect("fails to stop a task Copilot does not report as cancelled", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + yield* runtime.request(COPILOT_SDK_STOP_TASK_METHOD, { taskId: "task-1" }); + const failure = yield* runtime + .request(COPILOT_SDK_STOP_TASK_METHOD, { taskId: "finished" }) + .pipe( + Effect.as(""), + Effect.catch((error) => Effect.succeed(error.message)), + ); + + assert.include(failure, "no running task"); + }).pipe(Effect.scoped), +); + +it.effect("totals the main agent's usage across a turn and ignores subagents", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + fakes.emit(event("assistant.usage", { inputTokens: 10, outputTokens: 2, cacheReadTokens: 4 })); + fakes.emit(event("assistant.usage", { inputTokens: 5, outputTokens: 1 })); + fakes.emit(event("assistant.usage", { inputTokens: 99, parentToolCallId: "task-1" })); + fakes.emit(event("session.idle")); + + assert.deepEqual((yield* Fiber.join(prompt)).usage, { + inputTokens: 15, + outputTokens: 3, + cachedReadTokens: 4, + totalTokens: 18, + }); + }).pipe(Effect.scoped), +); + +// regression: an unacknowledged answer shows "Answer delivery not confirmed" and fails the turn +it.effect("reports each permission and question answer as sent, and keeps question choices", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const sent: Array = []; + const runtime = yield* makeCopilotSdkRuntime({ + cwd: "/repo", + client: fakes.client, + onOutgoingResponse: (requestId) => + Effect.sync(() => { + sent.push(requestId); + }), + }); + const contexts: Array = []; + let schema: unknown; + yield* runtime.handleRequestPermission((_, context) => + Effect.sync(() => { + contexts.push(context.requestId); + return { outcome: { outcome: "selected", optionId: "reject_once" } }; + }), + ); + yield* runtime.handleElicitation((request, context) => + Effect.sync(() => { + contexts.push(context.requestId); + schema = "requestedSchema" in request ? request.requestedSchema : undefined; + return { action: "accept", content: { answer: "red" } }; + }), + ); + yield* runtime.start(); + const { onPermissionRequest, onUserInputRequest } = fakes.sessionConfig(); + + const decision = yield* Effect.promise(async () => + onPermissionRequest!({ kind: "shell", toolCallId: "t1" } as PermissionRequest, { + sessionId: "session-1", + }), + ); + const answer = yield* Effect.promise(async () => + onUserInputRequest!( + { question: "Pick", choices: ["red", "blue"], allowFreeform: true }, + { sessionId: "session-1" }, + ), + ); + + assert.deepEqual(decision, { kind: "reject" }); + assert.deepEqual(answer, { answer: "red", wasFreeform: false }); + assert.deepEqual(sent, contexts); + assert.deepEqual( + (schema as { properties: { answer: { enum: Array } } }).properties.answer.enum, + ["red", "blue"], + ); + }).pipe(Effect.scoped), +); + +it.effect("rewinds to just after a turn, keeping that turn", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setHistory([ + { ...event("user.message"), id: "u1" } as SessionEvent, + { ...event("assistant.message"), id: "a1" } as SessionEvent, + { ...event("user.message"), id: "u2" } as SessionEvent, + ]); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + yield* runtime.request(COPILOT_SDK_ROLLBACK_METHOD, { turnEventId: "u1" }); + yield* runtime.request(COPILOT_SDK_ROLLBACK_METHOD, { turnEventId: "u2" }); + + assert.deepEqual(fakes.calls, ["truncate:u2"]); + }).pipe(Effect.scoped), +); + +it.effect("fails a rewind to a turn the session does not have", () => + Effect.gen(function* () { + const fakes = makeFakes(); + fakes.setHistory([{ id: "u1", type: "user.message" } as SessionEvent]); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + yield* runtime.start(); + + const failure = yield* runtime + .request(COPILOT_SDK_ROLLBACK_METHOD, { turnEventId: "gone" }) + .pipe( + Effect.as(""), + Effect.catch((error) => Effect.succeed(error.message)), + ); + + assert.include(failure, "gone"); + assert.deepEqual(fakes.calls, []); + }).pipe(Effect.scoped), +); + +it.effect("carries a steer's images to Copilot", () => + Effect.gen(function* () { + const fakes = makeFakes(); + const runtime = yield* makeCopilotSdkRuntime({ cwd: "/repo", client: fakes.client }); + const prompt = yield* startPrompt(runtime); + fakes.emit(event("assistant.turn_start")); + + yield* runtime.request(COPILOT_SDK_STEER_METHOD, { + text: "see this", + images: [{ data: "aGk=", mimeType: "image/png" }], + }); + + assert.deepEqual(fakes.sentAttachments(), [ + [{ type: "blob", data: "aGk=", mimeType: "image/png" }], + ]); + fakes.emit(event("session.idle")); + yield* Fiber.join(prompt); + }).pipe(Effect.scoped), +); diff --git a/packages/provider-copilot/src/server/copilotSdkRuntime.ts b/packages/provider-copilot/src/server/copilotSdkRuntime.ts new file mode 100644 index 0000000000000..af97f075c0ed2 --- /dev/null +++ b/packages/provider-copilot/src/server/copilotSdkRuntime.ts @@ -0,0 +1,1719 @@ +import { + CopilotClient, + RuntimeConnection, + type MCPServerConfig, + type CopilotSession, + type ModelInfo, + type PermissionRequest, + type PermissionRequestResult, + type SessionConfig, + type SessionEvent, +} from "@github/copilot-sdk"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The SDK calls its hooks outside any Effect runtime. +import * as NodeFSP from "node:fs/promises"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The SDK client is built synchronously, outside any Effect runtime. +import * as NodeFS from "node:fs"; + +import type { AcpSessionRuntimeOptions } from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as AcpSchema from "effect-acp/compat"; +import * as AcpErrors from "effect-acp/errors"; + +const COPILOT_META_KEY = "github.com/copilot"; +const COPILOT_SESSION_EVENT = "github.com/copilot/sessionEvent"; +export const COPILOT_SDK_STEER_METHOD = "t3/copilot/steer"; +export const COPILOT_SDK_COMPACT_METHOD = "t3/copilot/compact"; +export const COPILOT_SDK_ROLLBACK_METHOD = "t3/copilot/rollback"; +export const COPILOT_SDK_MCP_APP_METHOD = "t3/copilot/mcp-app"; +export const COPILOT_SDK_STOP_TASK_METHOD = "t3/copilot/stop-task"; +const PLAN_TOOL_CALL_PREFIX = "copilot-plan:"; +const PLAN_DECLINED_FEEDBACK = + "T3 Code is showing this plan to the user. Stop now and wait for their decision."; + +const MODE_URL = "https://agentclientprotocol.com/protocol/session-modes#"; +const SDK_MODE_BY_ACP_MODE = { + [`${MODE_URL}agent`]: "interactive", + [`${MODE_URL}plan`]: "plan", + [`${MODE_URL}autopilot`]: "autopilot", +} as const; +type AcpModeId = keyof typeof SDK_MODE_BY_ACP_MODE; +type SdkMode = (typeof SDK_MODE_BY_ACP_MODE)[AcpModeId]; + +const ACP_MODES = [ + { + id: `${MODE_URL}agent`, + name: "Agent", + description: "Default agent mode for conversational interactions", + }, + { + id: `${MODE_URL}plan`, + name: "Plan", + description: "Plan mode for creating and executing multi-step plans", + }, + { + id: `${MODE_URL}autopilot`, + name: "Autopilot", + description: + "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)", + }, +] as const; + +const REASONING_DESCRIPTIONS: Record = { + low: "Minimal reasoning before responding.", + medium: "Balanced reasoning and speed.", + high: "More thorough reasoning; slower responses.", +}; + +// The SDK exposes no connection-closed hook, so a dead runtime would otherwise hang the prompt. +const CONNECTION_WATCHDOG_INTERVAL = Duration.seconds(10); +const CONNECTION_PING_TIMEOUT = Duration.seconds(30); +const STOP_TIMEOUT = Duration.seconds(10); + +const INITIALIZE_RESULT = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { http: true, sse: true }, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + sessionCapabilities: { close: {}, fork: {}, list: {} }, + }, + agentInfo: { name: "Copilot", title: "Copilot", version: "sdk" }, + authMethods: [], +} satisfies AcpSchema.InitializeResponse; + +export interface CopilotSdkSession { + readonly sessionId: string; + readonly on: (handler: (event: SessionEvent) => void) => () => void; + readonly send: (options: { + readonly prompt: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "blob"; + readonly data: string; + readonly mimeType: string; + }>; + }) => Promise; + readonly abort: () => Promise; + readonly interruptMainTurn: (flushQueued: boolean) => Promise; + readonly currentModel: () => Promise<{ + readonly modelId?: string; + readonly reasoningEffort?: string; + }>; + readonly switchModel: (modelId: string) => Promise; + readonly setReasoningEffort: (effort: string) => Promise; + readonly setMode: (mode: SdkMode) => Promise; + readonly setApproveAll: (enabled: boolean) => Promise; + readonly history: () => Promise>; + readonly disconnect: () => Promise; + readonly compact: () => Promise<{ readonly success: boolean }>; + /** Removes this event and everything after it. */ + readonly truncate: (eventId: string) => Promise; + readonly commands: () => Promise>; + readonly invokeCommand: ( + name: string, + input: string | undefined, + ) => Promise; + /** Copilot cancels by agent id, while T3 holds the id of the tool call that started the agent. */ + readonly cancelTask: (toolCallId: string) => Promise; + readonly mcpAppTools: (server: string) => Promise>>; + readonly callMcpAppTool: (input: { + readonly server: string; + readonly tool: string; + readonly arguments: Record; + }) => Promise; + readonly readMcpAppResource: (server: string, uri: string) => Promise; +} + +export type CopilotSdkMcpAppRequest = + | { readonly operation: "listTools"; readonly server: string } + | { + readonly operation: "callTool"; + readonly server: string; + readonly tool: string; + readonly arguments: Record; + } + | { readonly operation: "readResource"; readonly server: string; readonly uri: string }; + +export interface CopilotSdkCommand { + readonly name: string; + readonly description: string; + readonly input?: { readonly hint: string }; +} + +export type CopilotSdkCommandResult = Awaited< + ReturnType +>; + +type CopilotSdkSessionConfig = Pick< + SessionConfig, + | "clientName" + | "mcpServers" + | "onExitPlanModeRequest" + | "onPermissionRequest" + | "onUserInputRequest" + | "streaming" + | "toolSearch" + | "enableMcpApps" + | "includeSubAgentStreamingEvents" + | "workingDirectory" +>; + +export interface CopilotSdkClient { + readonly start: () => Promise; + readonly ping: () => Promise; + readonly authStatus: () => Promise<{ + readonly isAuthenticated: boolean; + readonly login?: string; + }>; + readonly serverCommands: () => Promise>; + readonly listModels: () => Promise>; + readonly createSession: (config: CopilotSdkSessionConfig) => Promise; + readonly resumeSession: ( + sessionId: string, + config: CopilotSdkSessionConfig, + ) => Promise; + readonly fork: (sessionId: string, toEventId: string | undefined) => Promise; + /** A session's saved events, without resuming it. */ + readonly sessionHistory: (sessionId: string) => Promise>; + /** One prompt in a throwaway session with no tools, removed afterwards to keep history clean. */ + readonly generateText: (input: { + readonly cwd: string; + readonly model: string; + readonly prompt: string; + readonly timeoutMs: number; + /** Lets the model read the checkout, limited by `copilotInvestigationToolDecision`. */ + readonly investigate?: boolean; + }) => Promise; + readonly stop: () => Promise; + readonly forceStop: () => Promise; +} + +function wrapSession(session: Awaited>) { + return { + sessionId: session.sessionId, + on: (handler) => session.on(handler), + send: (options) => + session.send({ + prompt: options.prompt, + ...(options.attachments === undefined ? {} : { attachments: [...options.attachments] }), + }), + abort: () => session.abort(), + interruptMainTurn: async (flushQueued) => { + await session.rpc.interruptMainTurn({ flushQueued }); + }, + currentModel: () => session.rpc.model.getCurrent(), + switchModel: async (modelId) => { + await session.rpc.model.switchTo({ modelId }); + }, + setReasoningEffort: async (reasoningEffort) => { + await session.rpc.model.setReasoningEffort({ reasoningEffort }); + }, + setMode: async (mode) => { + await session.rpc.mode.set({ mode }); + }, + setApproveAll: async (enabled) => { + await session.rpc.permissions.setApproveAll({ enabled }); + }, + history: () => session.getEvents(), + disconnect: () => session.disconnect(), + compact: () => session.rpc.history.compact({ trigger: "manual" }), + truncate: async (eventId) => { + await session.rpc.history.truncate({ eventId }); + }, + commands: async () => (await session.rpc.commands.list()).commands, + invokeCommand: (name, input) => + session.rpc.commands.invoke({ name, ...(input === undefined ? {} : { input }) }), + mcpAppTools: async (serverName) => + (await session.rpc.mcp.apps.listTools({ serverName, originServerName: serverName })).tools, + callMcpAppTool: ({ server, tool, arguments: toolArguments }) => + session.rpc.mcp.apps.callTool({ + serverName: server, + toolName: tool, + arguments: toolArguments as Record, + originServerName: server, + }), + readMcpAppResource: (serverName, uri) => session.rpc.mcp.apps.readResource({ serverName, uri }), + cancelTask: async (toolCallId) => { + const { tasks } = await session.rpc.tasks.list(); + const task = tasks.find( + (candidate) => "toolCallId" in candidate && candidate.toolCallId === toolCallId, + ); + if (task === undefined) return false; + return (await session.rpc.tasks.cancel({ id: task.id })).cancelled; + }, + } satisfies CopilotSdkSession; +} + +const INVESTIGATION_TOOLS = ["view", "grep", "glob", "bash"]; +const READ_ONLY_GIT_COMMAND = /^git (?:diff|log|show)(?: [\w./:@^~=+,%-]+)*$/; +// An allow list: git has too many options that write or read files, and new ones keep arriving. +const SAFE_GIT_OPTION = + /^(?:--|-p|-s|-n\d*|-\d+|-U\d+|-w|--patch|--no-patch|--stat|--numstat|--shortstat|--summary|--name-only|--name-status|--oneline|--(?:format|pretty)=[\w%:.,+-]*|--max-count=\d+|--unified=\d+|--diff-filter=[A-Za-z]+|--reverse|--no-merges|--first-parent|--follow|--cached|--staged|--no-color|--no-renames)$/; +const INVESTIGATION_FEEDBACK = + "Read-only: only plain git diff, git log, and git show run, with common options and no pipes, and only files in this checkout can be read."; + +// `git diff` compares files anywhere once a path leaves the checkout, even without `--no-index`. +const staysInCheckout = (word: string) => + !/^(?:[/~]|[A-Za-z]:)/.test(word) && !word.split("/").includes(".."); + +const isReadOnlyGitCommand = (command: string) => + READ_ONLY_GIT_COMMAND.test(command) && + command + .split(" ") + .slice(2) + .every((word) => (word.startsWith("-") ? SAFE_GIT_OPTION.test(word) : staysInCheckout(word))); + +// Any other text argument counts as a path, so an argument we have not seen fails closed. +const NON_PATH_TOOL_ARGS = new Set(["pattern", "glob", "output_mode", "type", "description"]); + +const isCheckoutPattern = (pattern: string) => + !pattern.includes("..") && !/(?:^|[{,])\s*[/~]/.test(pattern); + +/** A change request can add a symlink out of the checkout, so its real path has to be inside. */ +async function resolvesInsideDirectory( + directory: string, + path: string, + resolvePath: (path: string) => Promise, +) { + if (!isInsideDirectory(directory, path)) return false; + try { + const [root, target] = await Promise.all([ + resolvePath(directory), + resolvePath(path.startsWith("/") ? path : `${directory}/${path}`), + ]); + return isInsideDirectory(root, target); + } catch { + return false; + } +} + +/** + * The enforcement point: Copilot runs commands it considers read-only, `git diff --output` + * included, without a permission request, but this hook sees every tool call. + */ +export async function copilotInvestigationToolDecision( + input: { readonly toolName: string; readonly toolArgs: unknown }, + cwd: string, + resolvePath: (path: string) => Promise = NodeFSP.realpath, +) { + const args = record(input.toolArgs); + const glob = stringField(args, input.toolName === "glob" ? "pattern" : "glob"); + const paths = Object.entries(args ?? {}).flatMap(([name, value]) => + NON_PATH_TOOL_ARGS.has(name) ? [] : [value].flat().filter((item) => typeof item === "string"), + ); + const allowed = + input.toolName === "bash" + ? isReadOnlyGitCommand(stringField(args, "command") ?? "") + : INVESTIGATION_TOOLS.includes(input.toolName) && + (glob === undefined || isCheckoutPattern(glob)) && + ( + await Promise.all(paths.map((path) => resolvesInsideDirectory(cwd, path, resolvePath))) + ).every(Boolean); + return allowed + ? ({ permissionDecision: "allow" } as const) + : ({ permissionDecision: "deny", permissionDecisionReason: INVESTIGATION_FEEDBACK } as const); +} + +/** Second line behind the hook, for anything that still asks. */ +export function copilotInvestigationDecision( + request: PermissionRequest, + cwd: string, +): PermissionRequestResult { + const allowed = + request.kind === "read" + ? isInsideDirectory(cwd, request.resolvedPath ?? request.path) + : request.kind === "shell" && + !request.hasWriteFileRedirection && + isReadOnlyGitCommand(request.fullCommandText); + return allowed ? { kind: "approve-once" } : { kind: "reject", feedback: INVESTIGATION_FEEDBACK }; +} + +function isInsideDirectory(directory: string, path: string) { + const absolute = path.startsWith("/") ? path : `${directory}/${path}`; + return ( + (absolute === directory || absolute.startsWith(`${directory}/`)) && + !absolute.split("/").includes("..") + ); +} + +export async function generateCopilotText( + client: Pick, + input: { + readonly cwd: string; + readonly model: string; + readonly prompt: string; + readonly timeoutMs: number; + readonly investigate?: boolean; + }, +) { + try { + await client.start(); + const session = await client.createSession({ + model: input.model, + workingDirectory: input.cwd, + availableTools: input.investigate === true ? INVESTIGATION_TOOLS : [], + onPermissionRequest: (request) => + input.investigate === true + ? copilotInvestigationDecision(request, input.cwd) + : { kind: "reject" }, + ...(input.investigate === true + ? { + hooks: { + onPreToolUse: (tool) => copilotInvestigationToolDecision(tool, input.cwd), + }, + } + : {}), + }); + try { + return (await session.sendAndWait(input.prompt, input.timeoutMs))?.data.content ?? ""; + } finally { + await session.disconnect().catch(() => undefined); + await client.deleteSession(session.sessionId).catch(() => undefined); + } + } finally { + await client.stop(); + } +} + +function definedEnvironment(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])), + ); +} + +/** The SDK starts the CLI without a shell, so npm's shim has to become the binary behind it. */ +export function copilotWindowsExecutable( + command: string, + arch: string, + exists: (path: string) => boolean = NodeFS.existsSync, +) { + if (!/\.(?:cmd|bat|ps1)$/i.test(command)) return command; + const directory = command.slice(0, Math.max(command.lastIndexOf("\\"), command.lastIndexOf("/"))); + const binary = `@github\\copilot-win32-${arch}\\copilot.exe`; + return ( + [ + `${directory}\\node_modules\\@github\\copilot\\node_modules\\${binary}`, + `${directory}\\node_modules\\${binary}`, + ].find(exists) ?? command + ); +} + +export const copilotLaunchCommand = Effect.fnUntraced(function* (command: string) { + return (yield* HostProcess.Platform) === "win32" + ? copilotWindowsExecutable(command, yield* HostProcess.Architecture) + : command; +}); + +export function makeCopilotSdkClient(input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; +}): CopilotSdkClient { + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ + path: input.command, + // The SDK starts the runtime in its own mode; `--acp` would select the wrong protocol. + args: input.args.filter((arg) => arg !== "--acp"), + // Copilot drops `enableMcpApps` unless this override or its own rollout flag is on. + env: { ...definedEnvironment(input.env ?? {}), COPILOT_MCP_APPS: "true" }, + }), + }); + return { + start: () => client.start(), + ping: () => client.ping(), + authStatus: () => client.getAuthStatus(), + serverCommands: async () => (await client.rpc.commands.list()).commands, + listModels: () => client.listModels(), + createSession: async (config) => wrapSession(await client.createSession(config)), + resumeSession: async (sessionId, config) => + wrapSession(await client.resumeSession(sessionId, config)), + fork: async (sessionId, toEventId) => + ( + await client.rpc.sessions.fork({ + sessionId, + ...(toEventId === undefined ? {} : { toEventId }), + }) + ).sessionId, + sessionHistory: async (sessionId) => { + const events: Array = []; + let cursor: string | undefined; + do { + const page = await client.rpc.sessions.readPersistedEvents({ + sessionId, + direction: "forward", + max: 1000, + ...(cursor === undefined ? {} : { cursor }), + }); + // An expired cursor ends in an empty page, which would pass for the end of the history. + if (page.cursorStatus !== "ok") throw new Error(`Copilot session ${sessionId} expired.`); + events.push(...page.events); + cursor = page.hasMore ? page.cursor : undefined; + } while (cursor !== undefined); + return events; + }, + generateText: (input) => generateCopilotText(client, input), + stop: () => client.stop(), + forceStop: () => client.forceStop(), + }; +} + +const TOOL_KINDS: Record = { + bash: "execute", + create: "edit", + edit: "edit", + fetch_copilot_cli_documentation: "fetch", + glob: "search", + grep: "search", + list_bash: "execute", + list_powershell: "execute", + powershell: "execute", + read_bash: "execute", + read_powershell: "execute", + stop_bash: "execute", + stop_powershell: "execute", + str_replace_editor: "edit", + view: "read", + web_fetch: "fetch", + write_bash: "execute", +}; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +const stringField = (value: Record | undefined, key: string) => + typeof value?.[key] === "string" ? value[key] : undefined; + +interface ToolIdentity { + readonly serverId?: string; + readonly toolName?: string; +} + +/** `serverId` and `toolName` are the top-level keys ACP tool parsing reads an MCP identity from. */ +function toolMeta( + agentId: string | undefined, + identity: ToolIdentity | undefined, + uiResource?: unknown, +) { + const copilot = { + ...(agentId === undefined ? {} : { agentId }), + ...(uiResource === undefined ? {} : { uiResource }), + }; + const meta = { + ...identity, + ...(Object.keys(copilot).length === 0 ? {} : { [COPILOT_META_KEY]: copilot }), + } as Record; + return Object.keys(meta).length === 0 ? {} : { _meta: meta }; +} + +/** Remembers each tool's input so a completed edit can report the same diff ACP does. */ +export function makeCopilotSdkEventTranslator() { + const toolInputs = new Map>(); + const openMainTools = new Set(); + const toolIdentities = new Map(); + + return (event: SessionEvent): ReadonlyArray => { + switch (event.type) { + case "assistant.message_delta": + return [ + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: event.data.deltaContent }, + }, + ]; + case "assistant.reasoning_delta": + return [ + { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: event.data.deltaContent }, + }, + ]; + case "tool.execution_start": { + const input = record(event.data.arguments) ?? {}; + toolInputs.set(event.data.toolCallId, input); + if (event.agentId === undefined && event.data.parentToolCallId === undefined) { + openMainTools.add(event.data.toolCallId); + } + const path = stringField(input, "path"); + const identity = + event.data.mcpServerName === undefined || event.data.mcpToolName === undefined + ? undefined + : { serverId: event.data.mcpServerName, toolName: event.data.mcpToolName }; + if (identity !== undefined) toolIdentities.set(event.data.toolCallId, identity); + return [ + { + sessionUpdate: "tool_call", + toolCallId: event.data.toolCallId, + title: stringField(input, "description") ?? event.data.toolTitle ?? event.data.toolName, + kind: TOOL_KINDS[event.data.toolName] ?? "other", + status: "pending", + rawInput: input, + ...(path === undefined ? {} : { locations: [{ path }] }), + ...toolMeta(event.agentId, identity), + }, + ]; + } + case "tool.execution_partial_result": + return [ + { + sessionUpdate: "tool_call_update", + toolCallId: event.data.toolCallId, + content: [ + { type: "content", content: { type: "text", text: event.data.partialOutput } }, + ], + ...toolMeta(event.agentId, toolIdentities.get(event.data.toolCallId)), + }, + ]; + case "tool.execution_complete": { + const input = toolInputs.get(event.data.toolCallId); + toolInputs.delete(event.data.toolCallId); + openMainTools.delete(event.data.toolCallId); + const path = stringField(input, "path"); + const newText = stringField(input, "new_str"); + const identity = toolIdentities.get(event.data.toolCallId); + toolIdentities.delete(event.data.toolCallId); + // The UI document travels in `_meta` so it never lands in the stored tool output. + const { uiResource, ...result } = event.data.result ?? {}; + const text = event.data.result?.content ?? event.data.error?.message; + const content: ReadonlyArray = + event.data.success && path !== undefined && newText !== undefined + ? [ + { + type: "diff", + path, + oldText: stringField(input, "old_str") ?? null, + newText, + }, + ] + : text === undefined + ? [] + : [{ type: "content", content: { type: "text", text } }]; + return [ + { + sessionUpdate: "tool_call_update", + toolCallId: event.data.toolCallId, + status: event.data.success ? "completed" : "failed", + ...(content.length === 0 ? {} : { content: [...content] }), + rawOutput: event.data.result === undefined ? (event.data.error ?? null) : result, + ...toolMeta(event.agentId, identity, uiResource), + }, + ]; + } + case "exit_plan_mode.requested": + return [ + { + sessionUpdate: "tool_call", + toolCallId: `${PLAN_TOOL_CALL_PREFIX}${event.data.requestId}`, + title: "Plan", + kind: "switch_mode", + status: "completed", + rawInput: { planContent: event.data.planContent, summary: event.data.summary }, + }, + ]; + case "abort": { + // Copilot kills the main agent's running tools here and never reports them again. + const interrupted = [...openMainTools]; + openMainTools.clear(); + return interrupted.map((toolCallId) => { + toolInputs.delete(toolCallId); + return { sessionUpdate: "tool_call_update", toolCallId, status: "failed" } as const; + }); + } + case "session.usage_info": + return [ + { + sessionUpdate: "usage_update", + used: event.data.currentTokens, + size: event.data.tokenLimit, + }, + ]; + case "session.title_changed": + return [{ sessionUpdate: "session_info_update", title: event.data.title }]; + case "assistant.message": + return event.data.parentToolCallId === undefined + ? [ + { + sessionUpdate: "session_info_update", + _meta: { [COPILOT_META_KEY]: { assistantMessageComplete: true } }, + }, + ] + : []; + case "user.message": + return event.data.messageId === undefined + ? [] + : [ + { + sessionUpdate: "session_info_update", + _meta: { + [COPILOT_META_KEY]: { userMessageId: event.data.messageId, eventId: event.id }, + }, + }, + ]; + default: + return []; + } + }; +} + +const withoutT3Instructions = (prompt: string) => + prompt.replace(/^[\s\S]*?<\/t3_code_instructions>\s*/, ""); + +/** History leaves out streaming deltas, so a replay renders each complete message instead. */ +export function copilotSdkHistoryMessage(event: SessionEvent): AcpSchema.SessionUpdate | undefined { + if (event.agentId !== undefined) return undefined; + const content = { type: "text", text: "" } as const; + switch (event.type) { + case "user.message": + return { + sessionUpdate: "user_message_chunk", + content: { ...content, text: withoutT3Instructions(event.data.content) }, + ...(event.data.messageId === undefined ? {} : { messageId: event.data.messageId }), + }; + case "assistant.message": + return event.data.parentToolCallId === undefined && event.data.content.length > 0 + ? { + sessionUpdate: "agent_message_chunk", + content: { ...content, text: event.data.content }, + messageId: event.data.messageId, + } + : undefined; + case "assistant.reasoning": + return { + sessionUpdate: "agent_thought_chunk", + content: { ...content, text: event.data.content }, + messageId: event.data.reasoningId, + }; + default: + return undefined; + } +} + +const decodeUserMessageMeta = Schema.decodeUnknownOption( + Schema.Struct({ + [COPILOT_META_KEY]: Schema.Struct({ userMessageId: Schema.String }), + }), +); + +export function copilotSdkInjectedSteerId( + notification: AcpSchema.SessionNotification, +): string | undefined { + if (notification.update.sessionUpdate !== "session_info_update") return undefined; + const decoded = decodeUserMessageMeta(notification.update._meta); + return decoded._tag === "Some" ? decoded.value[COPILOT_META_KEY].userMessageId : undefined; +} + +const decodeUserMessageEventMeta = Schema.decodeUnknownOption( + Schema.Struct({ [COPILOT_META_KEY]: Schema.Struct({ eventId: Schema.String }) }), +); + +const FORK_EVENT_META_KEY = "copilotForkEventId"; + +/** `toEventId` is exclusive, so a fork through a turn ends at the following user message. */ +export function copilotSdkForkBoundary( + history: ReadonlyArray>, + turnEventId: string, +): string | undefined { + const start = history.findIndex((event) => event.id === turnEventId); + if (start < 0) return undefined; + return history.slice(start + 1).find((event) => event.type === "user.message")?.id; +} + +export const copilotSdkForkFromTurn = { + supported: () => true, + nativeTurnId: (notification: AcpSchema.SessionNotification) => { + if (notification.update.sessionUpdate !== "session_info_update") return undefined; + const decoded = decodeUserMessageEventMeta(notification.update._meta); + return decoded._tag === "Some" ? decoded.value[COPILOT_META_KEY].eventId : undefined; + }, + forkMeta: (eventId: string) => ({ [FORK_EVENT_META_KEY]: eventId }), +}; + +export function copilotSdkProposedPlan( + notification: AcpSchema.SessionNotification, +): string | undefined { + const { update } = notification; + return update.sessionUpdate === "tool_call" && update.toolCallId.startsWith(PLAN_TOOL_CALL_PREFIX) + ? stringField(record(update.rawInput), "planContent") + : undefined; +} + +/** The UI document a finished MCP tool returned, shaped for T3's app snapshot. */ +export function copilotSdkMcpAppDocument(meta: unknown, rawOutput: unknown) { + const resource = record(record(record(meta)?.[COPILOT_META_KEY])?.uiResource); + const resourceUri = stringField(resource, "uri"); + if (resource === undefined || resourceUri === undefined) return undefined; + const output = record(rawOutput); + const text = stringField(output, "content"); + return { + resourceUri, + contents: [resource], + result: { + content: text === undefined ? [] : [{ type: "text", text }], + ...(output?.structuredContent === undefined + ? {} + : { structuredContent: output.structuredContent }), + }, + }; +} + +export const requestCopilotSdkMcpApp = ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + payload: CopilotSdkMcpAppRequest, +) => runtime.request(COPILOT_SDK_MCP_APP_METHOD, payload); + +export const rollbackCopilotSdkSession = ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + _sessionId: string, + turnEventId: string, +) => runtime.request(COPILOT_SDK_ROLLBACK_METHOD, { turnEventId }).pipe(Effect.asVoid); + +export const compactCopilotSdkSession = ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, +) => runtime.request(COPILOT_SDK_COMPACT_METHOD, { sessionId }).pipe(Effect.asVoid); + +export const stopCopilotSdkTask = ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + taskId: string, +) => runtime.request(COPILOT_SDK_STOP_TASK_METHOD, { taskId }).pipe(Effect.asVoid); + +const decodeAssistantMessageMeta = Schema.decodeUnknownOption( + Schema.Struct({ + [COPILOT_META_KEY]: Schema.Struct({ assistantMessageComplete: Schema.Literal(true) }), + }), +); + +export function copilotSdkClosesAssistantMessage(notification: AcpSchema.SessionNotification) { + return ( + notification.update.sessionUpdate === "session_info_update" && + decodeAssistantMessageMeta(notification.update._meta)._tag === "Some" + ); +} + +export const copilotSdkSteering = { + send: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + text: string, + images: ReadonlyArray<{ readonly data: string; readonly mimeType: string }>, + ) => + runtime.request(COPILOT_SDK_STEER_METHOD, { sessionId, text, images }).pipe( + Effect.flatMap((result) => { + const messageId = stringField(record(result), "messageId"); + return messageId === undefined + ? Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Copilot did not accept the steering message.", + method: COPILOT_SDK_STEER_METHOD, + data: result, + }), + ) + : Effect.succeed(messageId); + }), + ), + acceptsAttachments: true, + injectedSteerId: copilotSdkInjectedSteerId, +}; + +const FORWARDED_SESSION_EVENTS: ReadonlySet = new Set([ + "assistant.message_delta", + "assistant.reasoning_delta", + "subagent.started", + "subagent.completed", + "subagent.failed", +]); + +function copilotPermissionToolCall( + request: PermissionRequest, +): AcpSchema.RequestPermissionRequest["toolCall"] { + const input = record(request) ?? {}; + const toolCallId = stringField(input, "toolCallId") ?? `permission:${request.kind}`; + const title = stringField(input, "intention") ?? request.kind; + const kind: AcpSchema.ToolKind = + request.kind === "shell" + ? "execute" + : request.kind === "write" + ? "edit" + : request.kind === "read" + ? "read" + : request.kind === "url" + ? "fetch" + : "other"; + return { toolCallId, title, kind, status: "pending", rawInput: input }; +} + +const PERMISSION_OPTIONS = [ + { optionId: "allow_once", kind: "allow_once", name: "Allow once" }, + { optionId: "allow_always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Deny" }, +] satisfies AcpSchema.RequestPermissionRequest["options"]; + +export function copilotPermissionDecision( + response: AcpSchema.RequestPermissionResponse, +): PermissionRequestResult { + if (response.outcome.outcome !== "selected") return { kind: "reject" }; + switch (response.outcome.optionId) { + case "allow_once": + return { kind: "approve-once" }; + case "allow_always": + return { kind: "approve-for-session" }; + default: { + const feedback = stringField(record(response._meta), "feedback"); + return { kind: "reject", ...(feedback === undefined ? {} : { feedback }) }; + } + } +} + +function copilotSdkConfigOptions(input: { + readonly models: ReadonlyArray; + readonly modeId: string; + readonly modelId: string; + readonly reasoningEffort: string | undefined; + readonly allowAll: boolean; +}): ReadonlyArray { + const efforts = + input.models.find((model) => model.id === input.modelId)?.supportedReasoningEfforts ?? []; + return [ + { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + description: + "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot.", + currentValue: input.modeId, + options: ACP_MODES.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })), + }, + { + type: "select", + id: "model", + name: "Model", + category: "model", + description: "The AI model Copilot uses to generate responses.", + currentValue: input.modelId, + options: input.models.map((model) => ({ value: model.id, name: model.name })), + }, + ...(efforts.length === 0 + ? [] + : [ + { + type: "select" as const, + id: "reasoning_effort", + name: "Reasoning Effort", + category: "thought_level", + description: + "How much reasoning the model performs before responding. Higher effort can improve quality at the cost of speed.", + // Copilot often reports no level; an empty value still lets one be chosen. + currentValue: input.reasoningEffort ?? "", + options: efforts.map((effort) => ({ + value: effort, + name: effort, + ...(REASONING_DESCRIPTIONS[effort] === undefined + ? {} + : { description: REASONING_DESCRIPTIONS[effort] }), + })), + }, + ]), + { + type: "select", + id: "allow_all", + name: "Allow All", + category: "permissions", + description: + "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs.", + currentValue: input.allowAll ? "on" : "off", + options: [ + { + value: "on", + name: "On", + description: "Automatically approve all tool, path, and URL requests", + }, + { + value: "off", + name: "Off", + description: "Require approval for tool, path, and URL requests", + }, + ], + }, + ]; +} + +export function copilotSdkMcpServers( + servers: ReadonlyArray, +): Record { + const entries = servers.flatMap((server): ReadonlyArray<[string, MCPServerConfig]> => { + const fields = record(server); + const type = fields?.type; + const name = stringField(fields, "name"); + const url = stringField(fields, "url"); + if ((type !== "http" && type !== "sse") || name === undefined || url === undefined) return []; + const headers = Array.isArray(fields?.headers) ? fields.headers : []; + return [ + [ + name, + { + type, + url, + headers: Object.fromEntries( + headers.flatMap((header) => { + const headerName = stringField(record(header), "name"); + const value = stringField(record(header), "value"); + return headerName === undefined || value === undefined ? [] : [[headerName, value]]; + }), + ), + tools: ["*"], + }, + ], + ]; + }); + return Object.fromEntries(entries); +} + +function copilotSdkPrompt(prompt: ReadonlyArray) { + const text: Array = []; + const attachments: Array<{ + readonly type: "blob"; + readonly data: string; + readonly mimeType: string; + }> = []; + for (const block of prompt) { + if (block.type === "text") text.push(block.text); + if (block.type === "image") { + attachments.push({ type: "blob", data: block.data, mimeType: block.mimeType }); + } + if (block.type === "resource_link") text.push(block.uri); + } + return { + prompt: text.join("\n\n"), + ...(attachments.length === 0 ? {} : { attachments }), + }; +} + +/** T3 prepends its instructions to a session's first prompt; a command is what follows them. */ +export function copilotSlashCommand(prompt: string) { + const text = withoutT3Instructions(prompt); + const match = /^\/([\w-]+)(?:\s+([\s\S]*\S))?\s*$/.exec(text); + return match === null ? undefined : { name: match[1]!, input: match[2] }; +} + +function copilotCommandResultText(result: CopilotSdkCommandResult): string | undefined { + switch (result.kind) { + case "text": + return result.text; + case "completed": + return result.message; + case "set-plan-model": + return result.message; + case "select-subcommand": + return [result.title, ...result.options.map((option) => `- ${option.name}`)].join("\n"); + case "show-dialog": + return "Choose a model with T3 Code's model picker."; + default: + return undefined; + } +} + +const requestError = (method: string, cause: unknown) => + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: cause instanceof Error ? cause.message : String(cause), + method, + data: undefined, + }); + +const unsupported = (method: string) => + Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32601, + errorMessage: `Copilot's SDK transport does not support ${method}.`, + method, + data: undefined, + }), + ); + +interface ActivePrompt { + readonly done: Deferred.Deferred; + steered: boolean; +} + +export type CopilotSdkRuntimeOptions = Pick< + AcpSessionRuntimeOptions, + | "cwd" + | "httpMcpServers" + | "mcpServers" + | "onIncomingRequest" + | "onOutgoingResponse" + | "resumeSessionId" + | "transformSessionUpdate" +> & { + readonly client: CopilotSdkClient; + readonly onTermination?: (error: AcpErrors.AcpError) => Effect.Effect; +}; + +export const makeCopilotSdkRuntime = Effect.fn("makeCopilotSdkRuntime")(function* ( + options: CopilotSdkRuntimeOptions, +) { + const scope = yield* Scope.Scope; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const client = options.client; + const updates = yield* Queue.unbounded(); + const configOptions = yield* SubscriptionRef.make>( + [], + ); + const started = yield* Ref.make( + undefined, + ); + + let session: CopilotSdkSession | undefined; + let models: ReadonlyArray = []; + let modeId: string = `${MODE_URL}agent`; + let modelId = ""; + let reasoningEffort: string | undefined; + let allowAll = false; + let activePrompt: ActivePrompt | undefined; + // True while the main agent works. Interrupting after it finished loses the queued message. + let mainAgentBusy = false; + let unsubscribe: (() => void) | undefined; + let commands: ReadonlyArray = []; + let promptUsage: + | { inputTokens: number; outputTokens: number; cachedReadTokens: number } + | undefined; + + let sessionUpdateHandler: + | ((notification: AcpSchema.SessionNotification) => Effect.Effect) + | undefined; + let permissionHandler: + | Parameters[0] + | undefined; + let elicitationHandler: + | Parameters[0] + | undefined; + const extNotificationHandlers = new Map< + string, + (params: unknown) => Effect.Effect + >(); + + const translate = makeCopilotSdkEventTranslator(); + const requestSequence = { next: 0 }; + const nextRequestContext = (method: string) => ({ + requestId: `copilot-sdk:${requestSequence.next++}`, + method, + }); + + const sdk = (method: string, run: () => Promise) => + Effect.tryPromise({ try: run, catch: (cause) => requestError(method, cause) }); + + const publishConfig = Effect.suspend(() => + SubscriptionRef.set( + configOptions, + copilotSdkConfigOptions({ models, modeId, modelId, reasoningEffort, allowAll }), + ), + ); + + const emit = (sessionId: string, update: AcpSchema.SessionUpdate) => { + const notification: AcpSchema.SessionNotification = { sessionId, update }; + const handler = sessionUpdateHandler; + return handler === undefined + ? Effect.void + : handler(options.transformSessionUpdate?.(notification) ?? notification); + }; + + const settlePrompt = (result: Effect.Effect) => { + const prompt = activePrompt; + if (prompt === undefined) return Effect.void; + activePrompt = undefined; + return Deferred.completeWith(prompt.done, result).pipe(Effect.asVoid); + }; + + const refreshCommands = Effect.gen(function* () { + const active = session; + if (active === undefined) return; + commands = yield* sdk("session/commands", () => active.commands()); + yield* emit(active.sessionId, { + sessionUpdate: "available_commands_update", + availableCommands: commands.map((command) => ({ + name: command.name, + description: command.description, + ...(command.input === undefined + ? {} + : { input: { type: "text" as const, hint: command.input.hint } }), + })), + }); + }); + + const dispatch = Effect.fnUntraced(function* (event: SessionEvent) { + const sessionId = session?.sessionId; + if (sessionId === undefined) return; + if (FORWARDED_SESSION_EVENTS.has(event.type)) { + yield* ( + extNotificationHandlers.get(COPILOT_SESSION_EVENT)?.({ + sessionId, + type: event.type, + ...(event.agentId === undefined ? {} : { agentId: event.agentId }), + data: event.data, + }) ?? Effect.void + ); + } + for (const update of translate(event)) { + yield* emit(sessionId, update); + } + if (event.type === "assistant.usage" && event.data.parentToolCallId === undefined) { + promptUsage = { + inputTokens: (promptUsage?.inputTokens ?? 0) + (event.data.inputTokens ?? 0), + outputTokens: (promptUsage?.outputTokens ?? 0) + (event.data.outputTokens ?? 0), + cachedReadTokens: (promptUsage?.cachedReadTokens ?? 0) + (event.data.cacheReadTokens ?? 0), + }; + } + if (event.type === "commands.changed") yield* refreshCommands.pipe(Effect.ignore); + // Settling here keeps a turn from ending ahead of its own queued output. + if (event.type === "session.idle") { + const cancelled = event.data.aborted === true && activePrompt?.steered !== true; + const usage = promptUsage; + promptUsage = undefined; + yield* settlePrompt( + Effect.succeed({ + stopReason: cancelled ? "cancelled" : "end_turn", + ...(usage === undefined + ? {} + : { usage: { ...usage, totalTokens: usage.inputTokens + usage.outputTokens } }), + }), + ); + } + if (event.type === "session.error" && event.agentId === undefined) { + yield* settlePrompt( + Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: event.data.message, + method: "session/prompt", + data: { errorType: event.data.errorType }, + }), + ), + ); + } + }); + + yield* Queue.take(updates).pipe( + Effect.flatMap((event) => dispatch(event).pipe(Effect.ignore)), + Effect.forever, + Effect.forkIn(scope), + ); + + // Runs on the SDK's delivery callback so a steer always sees the current turn state. + const observe = (event: SessionEvent) => { + if (event.type === "assistant.turn_start") { + mainAgentBusy = true; + if (activePrompt !== undefined) activePrompt.steered = false; + } + if (event.type === "assistant.idle" || event.type === "session.idle") mainAgentBusy = false; + Queue.offerUnsafe(updates, event); + }; + + // The adapter holds an answer as unconfirmed until its transport reports the response sent. + const answered = ( + method: string, + answer: (context: ReturnType) => Effect.Effect, + ) => { + const context = nextRequestContext(method); + return (options.onIncomingRequest?.(context.requestId, method, undefined) ?? Effect.void).pipe( + Effect.andThen(answer(context)), + Effect.tap(() => options.onOutgoingResponse?.(context.requestId) ?? Effect.void), + ); + }; + + const onPermissionRequest: NonNullable = (request) => { + const handler = permissionHandler; + if (handler === undefined) return { kind: "reject" }; + return runPromise( + answered("session/request_permission", (context) => + handler( + { + sessionId: session?.sessionId ?? "", + toolCall: copilotPermissionToolCall(request), + options: PERMISSION_OPTIONS, + }, + context, + ), + ).pipe( + Effect.map(copilotPermissionDecision), + Effect.orElseSucceed((): PermissionRequestResult => ({ kind: "reject" })), + ), + ); + }; + + const onUserInputRequest: NonNullable = (request) => { + const handler = elicitationHandler; + if (handler === undefined) { + return Promise.reject(new Error("T3 Code cannot answer Copilot's question.")); + } + const choices = request.choices ?? []; + return runPromise( + answered("session/elicitation", (context) => + handler( + { + mode: "form", + sessionId: session?.sessionId ?? "", + message: request.question, + requestedSchema: { + type: "object", + properties: { + answer: { + type: "string", + title: request.question, + ...(choices.length === 0 ? {} : { enum: [...choices] }), + }, + }, + required: ["answer"], + }, + } as AcpSchema.CreateElicitationRequest, + context, + ), + ), + ).then((response) => { + const answer = stringField(record(record(response)?.content), "answer"); + if (answer === undefined) throw new Error("The question was declined."); + return { answer, wasFreeform: !choices.includes(answer) }; + }); + }; + + const sessionConfig = (activation?: AcpSessionRuntime.AcpSessionActivationOptions) => + ({ + clientName: "t3-code", + streaming: true, + includeSubAgentStreamingEvents: true, + enableMcpApps: true, + // Deferred tools halved how often models found the right one, and saved no tokens per task. + toolSearch: { enabled: false }, + workingDirectory: options.cwd, + mcpServers: copilotSdkMcpServers([ + ...(activation?.mcpServers ?? options.mcpServers ?? []), + ...(activation?.httpMcpServers ?? options.httpMcpServers ?? []), + ]), + onPermissionRequest, + onUserInputRequest, + // T3 owns plan approval: the plan is shown as a proposal and a later turn implements it. + onExitPlanModeRequest: () => ({ approved: false, feedback: PLAN_DECLINED_FEEDBACK }), + }) satisfies CopilotSdkSessionConfig; + + const adopt = Effect.fnUntraced(function* (next: CopilotSdkSession) { + unsubscribe?.(); + session = next; + unsubscribe = next.on(observe); + const current = yield* sdk("session/new", () => next.currentModel()); + modelId = current.modelId ?? models[0]?.id ?? ""; + const model = models.find((candidate) => candidate.id === modelId); + reasoningEffort = current.reasoningEffort ?? model?.defaultReasoningEffort; + yield* publishConfig; + yield* refreshCommands.pipe(Effect.ignore); + const result = { + sessionId: next.sessionId, + initializeResult: INITIALIZE_RESULT, + sessionSetupResult: { + sessionId: next.sessionId, + modes: { currentModeId: modeId, availableModes: [...ACP_MODES] }, + configOptions: [...(yield* SubscriptionRef.get(configOptions))], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult; + yield* Ref.set(started, result); + return result; + }); + + const connect = sdk("initialize", async () => { + try { + await client.start(); + } catch (cause) { + throw String(cause).includes("protocol version mismatch") + ? new Error( + "This Copilot CLI is not compatible with T3 Code's SDK transport. Update Copilot, or set Copilot transport to ACP.", + ) + : cause; + } + models = await client.listModels(); + }); + + const resume = Effect.fnUntraced(function* ( + sessionId: string, + activation?: AcpSessionRuntime.AcpSessionActivationOptions, + ) { + const next = yield* sdk("session/load", () => + client.resumeSession(sessionId, sessionConfig(activation)), + ); + const result = yield* adopt(next); + const replay = makeCopilotSdkEventTranslator(); + for (const event of yield* sdk("session/load", () => next.history())) { + const message = copilotSdkHistoryMessage(event); + if (message !== undefined) yield* emit(sessionId, message); + for (const update of replay(event)) yield* emit(sessionId, update); + } + return result; + }); + + const start = Effect.fnUntraced(function* () { + const current = yield* Ref.get(started); + if (current !== undefined) return current; + yield* connect; + if (options.resumeSessionId !== undefined) return yield* resume(options.resumeSessionId); + return yield* adopt(yield* sdk("session/new", () => client.createSession(sessionConfig()))); + }); + + const requireSession = Effect.suspend(() => + session === undefined + ? Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Copilot's session has not started.", + method: "session/prompt", + data: undefined, + }), + ) + : Effect.succeed(session), + ); + + const setConfigOption = Effect.fn("CopilotSdkRuntime.setConfigOption")(function* ( + configId: string, + value: string | boolean, + configOptionsInput?: { readonly allowUnlistedValue?: boolean }, + ) { + const active = yield* requireSession; + const method = "session/set_config_option"; + const text = String(value); + switch (configId) { + case "mode": { + const mode = SDK_MODE_BY_ACP_MODE[text as AcpModeId]; + if (mode === undefined) return yield* unsupported(`mode ${text}`); + yield* sdk(method, () => active.setMode(mode)); + modeId = text; + break; + } + case "model": + if ( + configOptionsInput?.allowUnlistedValue !== true && + !models.some((model) => model.id === text) + ) { + return yield* unsupported(`model ${text}`); + } + yield* sdk(method, () => active.switchModel(text)); + modelId = text; + reasoningEffort = models.find((model) => model.id === text)?.defaultReasoningEffort; + break; + case "reasoning_effort": + yield* sdk(method, () => active.setReasoningEffort(text)); + reasoningEffort = text; + break; + case "allow_all": + yield* sdk(method, () => active.setApproveAll(text === "on")); + allowAll = text === "on"; + break; + default: + return yield* unsupported(`config option ${configId}`); + } + yield* publishConfig; + return { configOptions: [...(yield* SubscriptionRef.get(configOptions))] }; + }); + + const forkSession = Effect.fn("CopilotSdkRuntime.forkSession")(function* ( + sessionId: string, + activation?: AcpSessionRuntime.AcpSessionActivationOptions, + meta?: unknown, + ) { + const turnEventId = stringField(record(meta), FORK_EVENT_META_KEY); + const active = yield* requireSession; + let boundary: string | undefined; + if (turnEventId !== undefined) { + // After an earlier fork the active session is that fork, not the one being forked. + const history = yield* sdk("session/fork", () => + active.sessionId === sessionId ? active.history() : client.sessionHistory(sessionId), + ); + if (!history.some((event) => event.id === turnEventId)) { + return yield* new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Copilot session ${sessionId} has no turn ${turnEventId}.`, + method: "session/fork", + }); + } + boundary = copilotSdkForkBoundary(history, turnEventId); + } + const forked = yield* sdk("session/fork", () => client.fork(sessionId, boundary)); + return yield* resume(forked, activation); + }); + + const steer = Effect.fnUntraced(function* (payload: unknown) { + const text = stringField(record(payload), "text"); + if (text === undefined) return yield* unsupported(COPILOT_SDK_STEER_METHOD); + const active = yield* requireSession; + const images = record(payload)?.images; + const attachments = (Array.isArray(images) ? images : []).flatMap((image) => { + const data = stringField(record(image), "data"); + const mimeType = stringField(record(image), "mimeType"); + return data === undefined || mimeType === undefined + ? [] + : [{ type: "blob" as const, data, mimeType }]; + }); + const messageId = yield* sdk(COPILOT_SDK_STEER_METHOD, () => + active.send({ prompt: text, ...(attachments.length === 0 ? {} : { attachments }) }), + ); + // Only an interrupt that lands on a working main agent flushes the queue. + if (mainAgentBusy) { + // The interrupt's abort belongs to the steer, not to a user cancelling the prompt. + if (activePrompt !== undefined) activePrompt.steered = true; + yield* sdk(COPILOT_SDK_STEER_METHOD, () => active.interruptMainTurn(true)); + } + return { messageId }; + }); + + const compact = Effect.gen(function* () { + const active = yield* requireSession; + const result = yield* sdk(COPILOT_SDK_COMPACT_METHOD, () => active.compact()); + if (!result.success) { + return yield* requestError( + COPILOT_SDK_COMPACT_METHOD, + "Copilot could not compact the conversation.", + ); + } + return result; + }); + + const rollback = Effect.fnUntraced(function* (payload: unknown) { + const turnEventId = stringField(record(payload), "turnEventId"); + if (turnEventId === undefined) return yield* unsupported(COPILOT_SDK_ROLLBACK_METHOD); + const active = yield* requireSession; + const history = yield* sdk(COPILOT_SDK_ROLLBACK_METHOD, () => active.history()); + if (!history.some((event) => event.id === turnEventId)) { + return yield* new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Copilot session ${active.sessionId} has no turn ${turnEventId}.`, + method: COPILOT_SDK_ROLLBACK_METHOD, + }); + } + const boundary = copilotSdkForkBoundary(history, turnEventId); + // No later user message means the session already ends at this turn. + if (boundary !== undefined) { + yield* sdk(COPILOT_SDK_ROLLBACK_METHOD, () => active.truncate(boundary)); + } + return {}; + }); + + const mcpApp = Effect.fnUntraced(function* (payload: unknown) { + const request = payload as CopilotSdkMcpAppRequest; + const active = yield* requireSession; + return yield* sdk(COPILOT_SDK_MCP_APP_METHOD, async (): Promise => { + switch (request.operation) { + case "listTools": + return await active.mcpAppTools(request.server); + case "callTool": + return await active.callMcpAppTool(request); + case "readResource": + return await active.readMcpAppResource(request.server, request.uri); + } + }); + }); + + const stopTask = Effect.fnUntraced(function* (payload: unknown) { + const taskId = stringField(record(payload), "taskId"); + if (taskId === undefined) return yield* unsupported(COPILOT_SDK_STOP_TASK_METHOD); + const active = yield* requireSession; + const cancelled = yield* sdk(COPILOT_SDK_STOP_TASK_METHOD, () => active.cancelTask(taskId)); + if (!cancelled) { + return yield* requestError( + COPILOT_SDK_STOP_TASK_METHOD, + `Copilot has no running task for ${taskId}.`, + ); + } + return {}; + }); + + const watchConnection = (prompt: ActivePrompt) => + Effect.sleep(CONNECTION_WATCHDOG_INTERVAL).pipe( + Effect.andThen( + sdk("session/prompt", () => client.ping()).pipe( + Effect.timeoutOrElse({ + duration: CONNECTION_PING_TIMEOUT, + orElse: () => + Effect.fail( + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Copilot stopped responding.", + method: "session/prompt", + }), + ), + }), + ), + ), + Effect.forever, + Effect.catch((error) => + // Settling ends the prompt, and with it this fiber: termination has to finish first. + Effect.uninterruptible( + (options.onTermination?.(error) ?? Effect.void).pipe( + Effect.andThen( + Effect.suspend(() => + activePrompt === prompt ? settlePrompt(Effect.fail(error)) : Effect.void, + ), + ), + ), + ), + ), + ); + + const prompt = Effect.fn("CopilotSdkRuntime.prompt")(function* ( + payload: Omit, + promptOptions?: { readonly dispatched?: Deferred.Deferred }, + ) { + const active = yield* requireSession; + const message = copilotSdkPrompt(payload.prompt); + const slash = copilotSlashCommand(message.prompt); + let outgoing = message; + // `send` drops a slash command silently, so a known command goes through `invoke`. + if (slash !== undefined && commands.some((command) => command.name === slash.name)) { + const result = yield* sdk("session/prompt", () => + active.invokeCommand(slash.name, slash.input), + ); + if (result.kind !== "agent-prompt") { + if (result.kind === "set-model") yield* setConfigOption("model", result.model); + const text = copilotCommandResultText(result); + if (text !== undefined) { + yield* emit(active.sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }).pipe(Effect.ignore); + } + if (promptOptions?.dispatched !== undefined) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return { stopReason: "end_turn" } satisfies AcpSchema.PromptResponse; + } + outgoing = { ...message, prompt: result.prompt }; + } + const done = yield* Deferred.make(); + const current: ActivePrompt = { done, steered: false }; + activePrompt = current; + mainAgentBusy = true; + promptUsage = undefined; + const watchdog = yield* watchConnection(current).pipe(Effect.forkChild); + return yield* Effect.gen(function* () { + yield* sdk("session/prompt", () => active.send(outgoing)); + if (promptOptions?.dispatched !== undefined) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return yield* Deferred.await(done); + }).pipe(Effect.ensuring(Fiber.interrupt(watchdog))); + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + unsubscribe?.(); + yield* sdk("session/close", () => client.stop()).pipe( + Effect.timeout(STOP_TIMEOUT), + Effect.catch(() => sdk("session/close", () => client.forceStop()).pipe(Effect.ignore)), + ); + }), + ); + + const ignoreHandler = () => Effect.void; + + return { + handleRequestPermission: (handler) => + Effect.sync(() => { + permissionHandler = handler; + }), + handleElicitation: (handler) => + Effect.sync(() => { + elicitationHandler = handler; + }), + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }), + handleExtNotification: (method, payload, handler) => + Effect.sync(() => { + const decode = Schema.decodeUnknownEffect(payload); + extNotificationHandlers.set(method, (params) => + decode(params).pipe( + Effect.option, + Effect.flatMap((decoded) => + decoded._tag === "Some" ? handler(decoded.value) : Effect.void, + ), + ), + ); + }), + handleMcpConnect: ignoreHandler, + handleMcpMessage: ignoreHandler, + handleMcpDisconnect: ignoreHandler, + handleMcpNotification: ignoreHandler, + handleReadTextFile: ignoreHandler, + handleWriteTextFile: ignoreHandler, + handleCreateTerminal: ignoreHandler, + handleTerminalOutput: ignoreHandler, + handleTerminalWaitForExit: ignoreHandler, + handleTerminalKill: ignoreHandler, + handleTerminalRelease: ignoreHandler, + handleElicitationComplete: ignoreHandler, + handleUnknownExtRequest: ignoreHandler, + handleUnknownExtNotification: ignoreHandler, + handleExtRequest: ignoreHandler, + initialize: () => Effect.succeed(INITIALIZE_RESULT), + start, + getEvents: () => Stream.empty, + drainEvents: Effect.void, + getModeState: Effect.sync(() => ({ + currentModeId: modeId, + availableModes: ACP_MODES.map((mode) => ({ ...mode })), + })), + getConfigOptions: SubscriptionRef.get(configOptions), + configOptionChanges: SubscriptionRef.changes(configOptions), + loadSession: (sessionId, activation) => + connect.pipe(Effect.andThen(resume(sessionId, activation))), + resumeSession: (sessionId, activation) => + connect.pipe(Effect.andThen(resume(sessionId, activation))), + forkSession, + listSessions: () => unsupported("session/list"), + closeSession: () => + requireSession.pipe( + Effect.flatMap((active) => sdk("session/close", () => active.disconnect())), + Effect.as({}), + ), + deleteSession: () => unsupported("session/delete"), + listProviders: unsupported("providers/list"), + setProvider: () => unsupported("providers/set"), + disableProvider: () => unsupported("providers/disable"), + logout: unsupported("logout"), + prompt, + cancel: requireSession.pipe( + Effect.flatMap((active) => sdk("session/cancel", () => active.abort())), + ), + setMode: (nextModeId) => setConfigOption("mode", nextModeId).pipe(Effect.as({})), + setSessionModel: (nextModelId) => setConfigOption("model", nextModelId).pipe(Effect.as({})), + setConfigOption, + setModel: (nextModelId) => setConfigOption("model", nextModelId).pipe(Effect.asVoid), + request: (method, payload) => + method === COPILOT_SDK_STEER_METHOD + ? steer(payload) + : method === COPILOT_SDK_COMPACT_METHOD + ? compact + : method === COPILOT_SDK_STOP_TASK_METHOD + ? stopTask(payload) + : method === COPILOT_SDK_ROLLBACK_METHOD + ? rollback(payload) + : method === COPILOT_SDK_MCP_APP_METHOD + ? mcpApp(payload) + : unsupported(method), + notify: (method) => unsupported(method), + } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; +}); diff --git a/packages/provider-copilot/src/server/driver.ts b/packages/provider-copilot/src/server/driver.ts new file mode 100644 index 0000000000000..56ad732977d90 --- /dev/null +++ b/packages/provider-copilot/src/server/driver.ts @@ -0,0 +1,150 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { GitHubCopilotSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +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 * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import { GitHubCopilotAdapterV2Driver, type GitHubCopilotAdapterV2DriverEnv } from "./adapter.ts"; +import { + buildInitialGitHubCopilotProviderSnapshot, + checkGitHubCopilotProviderStatus, +} from "./status.ts"; +import { makeGitHubCopilotTextGeneration } from "./textGeneration.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("githubCopilot"); +const decodeSettings = Schema.decodeSync(GitHubCopilotSettings); +// The Copilot CLI updates itself, so T3 offers no one-click update. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type GitHubCopilotDriverEnv = + | GitHubCopilotAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | ProviderHost.ProviderHost; + +export const GitHubCopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "GitHub Copilot", + supportsMultipleInstances: true, + }, + configSchema: GitHubCopilotSettings, + defaultConfig: (): GitHubCopilotSettings => decodeSettings({}), + create: Effect.fn("GitHubCopilotDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GitHubCopilotSettings; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialGitHubCopilotProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider: checkGitHubCopilotProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + // The cause keeps the failure; the detail reaches the snapshot, so it stays fixed. + detail: "Failed to build GitHub Copilot snapshot.", + cause, + }), + ), + ); + + const orchestrationAdapter = yield* GitHubCopilotAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build GitHub Copilot orchestration adapter.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: makeGitHubCopilotTextGeneration({ + settings: effectiveConfig, + environment: processEnv, + fileSystem, + path, + }), + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-copilot/src/server/status.test.ts b/packages/provider-copilot/src/server/status.test.ts new file mode 100644 index 0000000000000..a8982423ac050 --- /dev/null +++ b/packages/provider-copilot/src/server/status.test.ts @@ -0,0 +1,112 @@ +import { assert, it } from "@effect/vitest"; +import type { ModelInfo } from "@github/copilot-sdk"; +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 Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import type { CopilotSdkClient } from "./copilotSdkRuntime.ts"; +import { + gitHubCopilotAuth, + gitHubCopilotModels, + gitHubCopilotSlashCommands, + readGitHubCopilotAccount, +} from "./status.ts"; + +const model = (overrides: Partial) => + ({ id: "m", name: "M", capabilities: {}, ...overrides }) as ModelInfo; + +it("lists only usable models, each with its own reasoning levels", () => { + const models = gitHubCopilotModels([ + model({ + id: "fast", + name: "Fast", + supportedReasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }), + model({ id: "plain", name: "Plain" }), + model({ id: "blocked", policy: { state: "disabled", terms: "" } }), + ]); + + assert.deepEqual( + models.map((entry) => entry.slug), + ["fast", "plain"], + ); + const [descriptor] = models[0]?.capabilities?.optionDescriptors ?? []; + assert.strictEqual(descriptor?.id, "reasoning_effort"); + assert.deepEqual(models[1]?.capabilities?.optionDescriptors, []); +}); + +it("keeps one /compact, since T3 runs compaction itself", () => { + const commands = gitHubCopilotSlashCommands([ + { name: "compact", description: "Copilot's" }, + { name: "usage", description: "Show usage" }, + ]); + + assert.deepEqual( + commands.map((command) => command.name), + ["compact", "usage"], + ); +}); + +it("reports the signed-in account", () => { + assert.deepEqual(gitHubCopilotAuth({ isAuthenticated: true, login: "octocat" }), { + status: "authenticated", + label: "octocat", + }); + assert.deepEqual(gitHubCopilotAuth({ isAuthenticated: false }), { status: "unauthenticated" }); +}); + +const probeClient = (overrides: Partial) => + ({ + start: async () => undefined, + authStatus: async () => ({ isAuthenticated: false }), + stop: async () => undefined, + forceStop: async () => undefined, + ...overrides, + }) as CopilotSdkClient; + +it.effect("force stops a CLI that never finishes its handshake", () => + Effect.gen(function* () { + let forced = false; + const probe = yield* readGitHubCopilotAccount( + probeClient({ + start: () => new Promise(() => undefined), + forceStop: async () => { + forced = true; + }, + }), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust("30 seconds"); + + assert.isTrue(Option.isNone(yield* Fiber.join(probe))); + assert.isTrue(forced); + }), +); + +it.effect("force stops a CLI that will not shut down", () => + Effect.gen(function* () { + let forced = false; + const stopping = yield* Deferred.make(); + const probe = yield* readGitHubCopilotAccount( + probeClient({ + stop: () => { + Deferred.doneUnsafe(stopping, Exit.void); + return new Promise(() => undefined); + }, + forceStop: async () => { + forced = true; + }, + }), + ).pipe(Effect.forkChild); + + yield* Deferred.await(stopping); + yield* TestClock.adjust("2 seconds"); + + assert.isTrue(Option.isSome(yield* Fiber.join(probe))); + assert.isTrue(forced); + }), +); diff --git a/packages/provider-copilot/src/server/status.ts b/packages/provider-copilot/src/server/status.ts new file mode 100644 index 0000000000000..fcd7b084c6df5 --- /dev/null +++ b/packages/provider-copilot/src/server/status.ts @@ -0,0 +1,276 @@ +import type { ModelInfo } from "@github/copilot-sdk"; +import { + type ModelCapabilities, + ProviderDriverKind, + type ServerProviderAuth, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { + buildSelectOptionDescriptor, + buildServerProvider, + COMPACT_SLASH_COMMAND, + isCommandMissingCause, + parseGenericCliVersion, + type ProviderProbeResult, + providerModelsFromSettings, + type ServerProviderDraft, + spawnAndCollect, +} from "@t3tools/provider-core/server/snapshotProbe"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { ChildProcess, type ChildProcessSpawner } from "effect/process"; + +import type { GitHubCopilotSettings } from "../settings.ts"; +import { + copilotLaunchCommand, + makeCopilotSdkClient, + type CopilotSdkClient, + type CopilotSdkCommand, +} from "./copilotSdkRuntime.ts"; + +class GitHubCopilotProbeError extends Schema.TaggedError()( + "GitHubCopilotProbeError", + { cause: Schema.Defect() }, +) {} + +const DRIVER_KIND = ProviderDriverKind.make("githubCopilot"); +const PRESENTATION = { displayName: "GitHub Copilot" } as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const BUILT_IN_MODELS: ReadonlyArray = [ + { slug: "auto", name: "Auto", isCustom: false, capabilities: EMPTY_CAPABILITIES }, +]; +const VERSION_PROBE_TIMEOUT_MS = 10_000; +const LOGIN_HINT = "Run `copilot login`."; + +/** Only the models this account can use, each with its own reasoning levels. */ +const REASONING_LABELS: Readonly> = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +export function gitHubCopilotModels( + models: ReadonlyArray, +): ReadonlyArray { + return models.flatMap((model) => { + if (model.policy !== undefined && model.policy.state !== "enabled") return []; + const efforts = model.supportedReasoningEfforts ?? []; + return [ + { + slug: model.id, + name: model.name, + isCustom: false, + capabilities: + efforts.length === 0 + ? EMPTY_CAPABILITIES + : createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoning_effort", + label: "Reasoning", + options: efforts.map((effort) => ({ + value: effort, + label: REASONING_LABELS[effort] ?? effort, + isDefault: effort === model.defaultReasoningEffort, + })), + }), + ], + }), + }, + ]; + }); +} + +export function gitHubCopilotSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + return [ + COMPACT_SLASH_COMMAND, + ...commands + .filter((command) => command.name !== COMPACT_SLASH_COMMAND.name) + .map((command) => ({ + name: command.name, + description: command.description, + ...(command.input === undefined ? {} : { input: { hint: command.input.hint } }), + })), + ]; +} + +const snapshot = ( + settings: GitHubCopilotSettings, + checkedAt: string, + probe: ProviderProbeResult, + listed: ReadonlyArray = BUILT_IN_MODELS, + slashCommands: ReadonlyArray = [COMPACT_SLASH_COMMAND], +) => + buildServerProvider({ + driver: DRIVER_KIND, + presentation: PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: providerModelsFromSettings(listed, settings.customModels, EMPTY_CAPABILITIES), + slashCommands, + probe, + }); + +export const buildInitialGitHubCopilotProviderSnapshot = Effect.fn( + "GitHubCopilotProvider.buildInitialSnapshot", +)(function* (settings: GitHubCopilotSettings): Effect.fn.Return { + const now = yield* DateTime.now; + return snapshot(settings, DateTime.formatIso(now), { + installed: settings.enabled, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking GitHub Copilot CLI availability..." + : "GitHub Copilot is disabled in T3 Code settings.", + }); +}); + +const runCopilotVersion = Effect.fn("GitHubCopilotProvider.runCopilotVersion")(function* ( + command: string, + environment: NodeJS.ProcessEnv, +) { + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +const CLIENT_STOP_TIMEOUT_MS = 2_000; +// A first start took 9.7s on a fresh Windows machine, against 0.9s once warm. +const ACCOUNT_PROBE_TIMEOUT_MS = 30_000; + +/** Reads everything without opening a session, so a status check leaves no Copilot history. */ +export const readGitHubCopilotAccount = (client: CopilotSdkClient) => { + const probe = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => new GitHubCopilotProbeError({ cause }), + }); + const forceStop = probe(() => client.forceStop()).pipe(Effect.ignore); + const stop = probe(() => client.stop()).pipe( + Effect.timeoutOption(CLIENT_STOP_TIMEOUT_MS), + Effect.flatMap(Option.match({ onNone: () => forceStop, onSome: () => Effect.void })), + Effect.ignore, + Effect.interruptible, + ); + // A CLI that never finishes its handshake leaves `start` pending, and only `forceStop` ends it. + return probe(async () => { + await client.start(); + const auth = await client.authStatus(); + if (!auth.isAuthenticated) return { auth, models: [], commands: [] }; + const [models, commands] = await Promise.all([client.listModels(), client.serverCommands()]); + return { auth, models, commands }; + }).pipe( + Effect.timeoutOption(ACCOUNT_PROBE_TIMEOUT_MS), + Effect.onExit((exit) => (Exit.isSuccess(exit) && Option.isSome(exit.value) ? stop : forceStop)), + ); +}; + +export function gitHubCopilotAuth(input: { + readonly isAuthenticated: boolean; + readonly login?: string; +}): ServerProviderAuth { + return input.isAuthenticated + ? { status: "authenticated", ...(input.login === undefined ? {} : { label: input.login }) } + : { status: "unauthenticated" }; +} + +export const checkGitHubCopilotProviderStatus = Effect.fn("checkGitHubCopilotProviderStatus")( + function* ( + settings: GitHubCopilotSettings, + environment: NodeJS.ProcessEnv, + makeClient: typeof makeCopilotSdkClient = makeCopilotSdkClient, + ): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + > { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!settings.enabled) { + return snapshot(settings, checkedAt, { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "GitHub Copilot is disabled in T3 Code settings.", + }); + } + + const command = settings.binaryPath || "copilot"; + const versionResult = yield* runCopilotVersion(command, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult) || Option.isNone(versionResult.success)) { + const missing = + Result.isFailure(versionResult) && isCommandMissingCause(versionResult.failure); + return snapshot(settings, checkedAt, { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "GitHub Copilot CLI (`copilot`) is not installed or not on PATH." + : "Failed to run `copilot --version`.", + }); + } + const output = versionResult.success.value; + const version = parseGenericCliVersion(`${output.stdout}\n${output.stderr}`); + + // The SDK only checks that the file exists, so it never searches PATH itself. + const resolved = yield* resolveCommandPath(command, { env: environment }); + const account = yield* readGitHubCopilotAccount( + makeClient({ + command: yield* copilotLaunchCommand(resolved ?? command), + args: [], + env: environment, + }), + ).pipe(Effect.result); + if (Result.isFailure(account) || Option.isNone(account.success)) { + return snapshot(settings, checkedAt, { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not read your GitHub Copilot account from the CLI.", + }); + } + const { auth, models, commands } = account.success.value; + if (!auth.isAuthenticated) { + return snapshot(settings, checkedAt, { + installed: true, + version, + status: "error", + auth: gitHubCopilotAuth(auth), + message: `GitHub Copilot CLI is installed but not signed in. ${LOGIN_HINT}`, + }); + } + const listed = gitHubCopilotModels(models); + return snapshot( + settings, + checkedAt, + { installed: true, version, status: "ready", auth: gitHubCopilotAuth(auth) }, + listed.length === 0 ? BUILT_IN_MODELS : listed, + gitHubCopilotSlashCommands(commands), + ); + }, +); diff --git a/packages/provider-copilot/src/server/textGeneration.test.ts b/packages/provider-copilot/src/server/textGeneration.test.ts new file mode 100644 index 0000000000000..bb6952eefa8d9 --- /dev/null +++ b/packages/provider-copilot/src/server/textGeneration.test.ts @@ -0,0 +1,197 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { GitHubCopilotSettings } from "../settings.ts"; +import type { CopilotSdkClient } from "./copilotSdkRuntime.ts"; +import { makeGitHubCopilotTextGeneration } from "./textGeneration.ts"; + +const settings = Schema.decodeSync(GitHubCopilotSettings)({ binaryPath: process.execPath }); + +it.layer(NodeServices.layer)("GitHub Copilot text generation", (it) => { + it.effect("reads the model's JSON reply and sends the selected model", () => + Effect.gen(function* () { + let model: string | undefined; + const generation = makeGitHubCopilotTextGeneration({ + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + makeClient: () => + ({ + generateText: async (input: { readonly model: string }) => { + model = input.model; + return 'Here you go: {"title":"Fix the login flow"}'; + }, + }) as unknown as CopilotSdkClient, + }); + + const result = yield* generation.generateThreadTitle({ + cwd: "/repo", + message: "the login is broken", + modelSelection: { + instanceId: ProviderInstanceId.make("githubCopilot"), + model: "claude-haiku-4.5", + }, + }); + + assert.strictEqual(result.title, "Fix the login flow"); + assert.strictEqual(model, "claude-haiku-4.5"); + }), + ); + + it.effect("gives a review guide read access and the investigation time limit", () => + Effect.gen(function* () { + let sent: { readonly investigate?: boolean; readonly timeoutMs: number } | undefined; + const generation = makeGitHubCopilotTextGeneration({ + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + makeClient: () => + ({ + generateText: async (input: NonNullable) => { + sent = input; + return '{"stops":[]}'; + }, + }) as unknown as CopilotSdkClient, + }); + + yield* generation + .generateReviewGuide({ + cwd: "/repo", + number: 1, + title: "t", + description: "", + baseBranch: "main", + baseCommit: "abc", + settledReview: "", + modelSelection: { + instanceId: ProviderInstanceId.make("githubCopilot"), + model: "claude-haiku-4.5", + }, + }) + .pipe(Effect.ignore, Effect.provideService(HostProcess.Platform, "darwin")); + + assert.isTrue(sent?.investigate); + assert.strictEqual(sent?.timeoutMs, 900_000); + }), + ); + + it.effect("refuses review guides on Windows, where the read-only rules do not apply", () => + Effect.gen(function* () { + const generation = makeGitHubCopilotTextGeneration({ + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + makeClient: () => assert.fail("must not start the CLI"), + }); + + const failure = yield* generation + .generateReviewGuide({ + cwd: "/repo", + number: 1, + title: "t", + description: "", + baseBranch: "main", + baseCommit: "abc", + settledReview: "", + modelSelection: { + instanceId: ProviderInstanceId.make("githubCopilot"), + model: "claude-haiku-4.5", + }, + }) + .pipe(Effect.flip, Effect.provideService(HostProcess.Platform, "win32")); + + assert.include(failure.detail, "cannot investigate"); + }), + ); + + it.effect("force stops a CLI that never answers", () => + Effect.gen(function* () { + let forced = false; + const started = yield* Deferred.make(); + const generation = makeGitHubCopilotTextGeneration({ + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + makeClient: () => + ({ + generateText: () => { + Deferred.doneUnsafe(started, Exit.void); + return new Promise(() => undefined); + }, + forceStop: async () => { + forced = true; + }, + }) as unknown as CopilotSdkClient, + }); + + const title = yield* generation + .generateThreadTitle({ + cwd: "/repo", + message: "the login is broken", + modelSelection: { + instanceId: ProviderInstanceId.make("githubCopilot"), + model: "claude-haiku-4.5", + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("210 seconds"); + + const failure = yield* Fiber.join(title); + assert.include(failure.detail, "did not respond"); + assert.isTrue(forced); + }), + ); + + it.effect("force stops the CLI when generation is interrupted", () => + Effect.gen(function* () { + let forced = false; + const started = yield* Deferred.make(); + const generation = makeGitHubCopilotTextGeneration({ + settings, + environment: {}, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + makeClient: () => + ({ + generateText: () => { + Deferred.doneUnsafe(started, Exit.void); + return new Promise(() => undefined); + }, + forceStop: async () => { + forced = true; + }, + }) as unknown as CopilotSdkClient, + }); + + const title = yield* generation + .generateThreadTitle({ + cwd: "/repo", + message: "the login is broken", + modelSelection: { + instanceId: ProviderInstanceId.make("githubCopilot"), + model: "claude-haiku-4.5", + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(title); + + assert.isTrue(forced); + }), + ); +}); diff --git a/packages/provider-copilot/src/server/textGeneration.ts b/packages/provider-copilot/src/server/textGeneration.ts new file mode 100644 index 0000000000000..35aa7afdd55df --- /dev/null +++ b/packages/provider-copilot/src/server/textGeneration.ts @@ -0,0 +1,96 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { + decodeJsonReply, + fromRunner, + investigationUnsupported, + type Request, +} from "@t3tools/provider-core/server/textGenerationOperations"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as Schema from "effect/Schema"; + +import type { GitHubCopilotSettings } from "../settings.ts"; +import { copilotLaunchCommand, makeCopilotSdkClient } from "./copilotSdkRuntime.ts"; + +const PROVIDER = "GitHub Copilot"; +const TIMEOUT_MS = 180_000; +// The reply timeout does not cover startup, so a stalled CLI handshake needs its own limit. +const STARTUP_TIMEOUT_MS = 30_000; + +export function makeGitHubCopilotTextGeneration(input: { + readonly settings: GitHubCopilotSettings; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly makeClient?: typeof makeCopilotSdkClient; +}): ProviderTextGeneration { + const makeClient = input.makeClient ?? makeCopilotSdkClient; + const run = Effect.fnUntraced(function* (request: Request) { + // The read-only rules are written for bash; Copilot's Windows shell is PowerShell. + if (request.investigate !== undefined && (yield* HostProcess.Platform) === "win32") { + return yield* investigationUnsupported(request.operation, PROVIDER); + } + const binary = input.settings.binaryPath || "copilot"; + const command = yield* resolveCommandPath(binary, { env: input.environment }).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provideService(Path.Path, input.path), + ); + if (command === null) { + return yield* new TextGenerationError({ + operation: request.operation, + detail: `${PROVIDER} CLI (${binary}) is not installed or not on PATH.`, + }); + } + const client = makeClient({ + command: yield* copilotLaunchCommand(command), + args: [], + env: input.environment, + }); + const timeoutMs = request.investigate?.timeoutMs ?? TIMEOUT_MS; + const reply = yield* Effect.tryPromise({ + try: () => + client.generateText({ + cwd: request.cwd, + model: request.modelSelection.model, + prompt: request.prompt, + timeoutMs, + investigate: request.investigate !== undefined, + }), + catch: (cause) => + new TextGenerationError({ + operation: request.operation, + detail: `${PROVIDER} could not generate text.`, + cause, + }), + }).pipe( + // The SDK cannot cancel a pending call, so an abandoned one has to end the CLI itself. + Effect.onInterrupt(() => + Effect.tryPromise({ + try: () => client.forceStop(), + catch: (cause) => + new TextGenerationError({ + operation: request.operation, + detail: `${PROVIDER} could not be stopped.`, + cause, + }), + }).pipe(Effect.ignore), + ), + Effect.timeoutOrElse({ + duration: timeoutMs + STARTUP_TIMEOUT_MS, + orElse: () => + Effect.fail( + new TextGenerationError({ + operation: request.operation, + detail: `${PROVIDER} did not respond in time.`, + }), + ), + }), + ); + return yield* decodeJsonReply(request, PROVIDER, reply); + }); + return fromRunner("GitHubCopilotTextGeneration", run); +} diff --git a/packages/provider-copilot/src/settings.ts b/packages/provider-copilot/src/settings.ts new file mode 100644 index 0000000000000..cdc34f4f5c818 --- /dev/null +++ b/packages/provider-copilot/src/settings.ts @@ -0,0 +1,32 @@ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const GitHubCopilotSettings = makeProviderSettingsSchema( + { + // Off by default: 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(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type GitHubCopilotSettings = typeof GitHubCopilotSettings.Type; diff --git a/packages/provider-copilot/tsconfig.json b/packages/provider-copilot/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-copilot/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 298613bf13c91..54087b6517c6c 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -3,7 +3,9 @@ import type { ChatAttachment, ServerSettings, ServerSettingsError, + ThreadId, } from "@t3tools/contracts"; +import type { McpAppReference } from "@t3tools/shared/mcpApp"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Option from "effect/Option"; @@ -47,6 +49,14 @@ export class ProviderHost extends Context.Service< /** Absolute attachment path, or null if the ID does not resolve inside attachmentsDir. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; + /** Stores an MCP App's document for the thread; undefined when it cannot be rendered. */ + readonly snapshotMcpApp?: (input: { + readonly threadId: ThreadId; + readonly server: string; + readonly tool: string; + readonly resourceUri: string; + readonly contents: ReadonlyArray; + }) => Effect.Effect; readonly credentials: ( namespace: string, bindingId: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e1dd4d26a461..96ae2d5746ead 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,14 @@ overrides: '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64': '-' + '@github/copilot-sdk>@github/copilot-sdk-darwin-arm64': '-' + '@github/copilot-sdk>@github/copilot-sdk-darwin-x64': '-' + '@github/copilot-sdk>@github/copilot-sdk-linux-arm64': '-' + '@github/copilot-sdk>@github/copilot-sdk-linux-x64': '-' + '@github/copilot-sdk>@github/copilot-sdk-linuxmusl-arm64': '-' + '@github/copilot-sdk>@github/copilot-sdk-linuxmusl-x64': '-' + '@github/copilot-sdk>@github/copilot-sdk-win32-arm64': '-' + '@github/copilot-sdk>@github/copilot-sdk-win32-x64': '-' '@clerk/backend': 3.25.0 '@clerk/clerk-js': 6.40.0 '@clerk/clerk-js>@base-org/account': '-' @@ -852,6 +860,9 @@ importers: '@t3tools/provider-acp-registry': specifier: workspace:* version: link:../../packages/provider-acp-registry + '@t3tools/provider-copilot': + specifier: workspace:* + version: link:../../packages/provider-copilot '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -1491,6 +1502,9 @@ importers: '@t3tools/provider-acp-registry': specifier: workspace:* version: link:../provider-acp-registry + '@t3tools/provider-copilot': + specifier: workspace:* + version: link:../provider-copilot '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core @@ -1678,6 +1692,46 @@ importers: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-copilot: + dependencies: + '@github/copilot-sdk': + specifier: 1.0.28 + version: 1.0.28 + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-acp-registry': + specifier: workspace:* + version: link:../provider-acp-registry + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/platform-node': + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: dependencies: '@t3tools/contracts': @@ -4378,6 +4432,10 @@ packages: '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@github/copilot-sdk@1.0.28': + resolution: {integrity: sha512-FCbSH9R0j9Wg2BYRAdurw2rncmXV9YTwdUzL75W561uqm3grSXwcJnYjCpc1nlDCa84lJriFC634Luigg4yqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + '@handy-computer/models@0.2.3': resolution: {integrity: sha512-EcyPojMY8hG5adDZGR/2h/m9jniqAnSND00gmiAErRLB1BQqMD9jrJaqsfoYF/UfPC8ocKZFUNSkTegoYqjF5w==} engines: {node: '>=20.10'} @@ -4749,56 +4807,111 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@koromix/koffi-android-arm64@3.2.1': + resolution: {integrity: sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==} + cpu: [arm64] + os: [android] + '@koromix/koffi-android-arm64@3.3.6': resolution: {integrity: sha512-wxsPuEMDrqOR5RUdABYFNDTabhh/xwDetCUDI+/CN93QF1T7Yeq218kvDWmYT/g1wV2kU1aYdRO4D7cH9PDskw==} cpu: [arm64] os: [android] + '@koromix/koffi-android-x64@3.2.1': + resolution: {integrity: sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==} + cpu: [x64] + os: [android] + '@koromix/koffi-android-x64@3.3.6': resolution: {integrity: sha512-fHZE6ejYLmAEe2eou2uoG61HUtolY45RhQEsTKAKNI5eKSW7Z2Jh8eP5EE+3CVQSbzbaMeHXRTcgEQGk5uEF5Q==} cpu: [x64] os: [android] + '@koromix/koffi-darwin-arm64@3.2.1': + resolution: {integrity: sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==} + cpu: [arm64] + os: [darwin] + '@koromix/koffi-darwin-arm64@3.3.6': resolution: {integrity: sha512-++V4qscXff/Ya5cE6vOPOomcxk3T5JQu81Uc0fvoso3VVELcoHt2/dz9E0FY0EPyQNIRcWOVg7CgvsPCKId9lg==} cpu: [arm64] os: [darwin] + '@koromix/koffi-darwin-x64@3.2.1': + resolution: {integrity: sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==} + cpu: [x64] + os: [darwin] + '@koromix/koffi-darwin-x64@3.3.6': resolution: {integrity: sha512-eOyit0eyfkmdXCNlOkG8cNtpQUx+dikr1QsozJdwm7/zH1u7KP5dRtmsaGUMLSUY7cVYSRTUStkwHBrHjBtuzw==} cpu: [x64] os: [darwin] + '@koromix/koffi-freebsd-arm64@3.2.1': + resolution: {integrity: sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==} + cpu: [arm64] + os: [freebsd] + '@koromix/koffi-freebsd-arm64@3.3.6': resolution: {integrity: sha512-7teGsq2BukkLZ60xxq4KyPxeJct51nV1G/MfNj8IoMTqlpd35w4aiIIoFWVwLL3j3DdT95Ye/FpVY4C7ZEuMHg==} cpu: [arm64] os: [freebsd] + '@koromix/koffi-freebsd-ia32@3.2.1': + resolution: {integrity: sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==} + cpu: [ia32] + os: [freebsd] + '@koromix/koffi-freebsd-ia32@3.3.6': resolution: {integrity: sha512-WA7qEAMxTWS2WSFDceETVJTFrZgRwNvfG+FJpCC2XkJXftHwOhv8/X6d5HUEXh361sd7HwCHoT1wypL0nvI7Mw==} cpu: [ia32] os: [freebsd] + '@koromix/koffi-freebsd-x64@3.2.1': + resolution: {integrity: sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==} + cpu: [x64] + os: [freebsd] + '@koromix/koffi-freebsd-x64@3.3.6': resolution: {integrity: sha512-OQ/eBhGefbtxcPs5VDkK9mwxoIeSwrIriOT1/JHHA/tbNAXdC0GiAYsBw82T4L4r2u0TuJ0CbgN3HrulzdPPig==} cpu: [x64] os: [freebsd] + '@koromix/koffi-linux-arm64@3.2.1': + resolution: {integrity: sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==} + cpu: [arm64] + os: [linux] + '@koromix/koffi-linux-arm64@3.3.6': resolution: {integrity: sha512-8LuJcdH74wTg7JcZjnRnb3z+qWPKcEDSv8HRPkWTVD56TFm0CXZXgl/WJ4maDbWem6ZoXxc87a8nQEA+wABzNA==} cpu: [arm64] os: [linux] + '@koromix/koffi-linux-arm@3.2.1': + resolution: {integrity: sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==} + cpu: [arm] + os: [linux] + '@koromix/koffi-linux-arm@3.3.6': resolution: {integrity: sha512-0uo15z9L7GsAEGvPCVKcXzCtRJCyghCYDrWO2L3vXk4G5lgk+OBvWS7RmlDZ99X/A0oDiL3vLCplGaBCEZZ8Eg==} cpu: [arm] os: [linux] + '@koromix/koffi-linux-ia32@3.2.1': + resolution: {integrity: sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==} + cpu: [ia32] + os: [linux] + '@koromix/koffi-linux-ia32@3.3.6': resolution: {integrity: sha512-aTOjTMdy2Q6JqNA9KkT2OaCs77d0TzEG5KipfinzDyiV9DkYyUCvFykIY5GDHCUKXyJi+xy1IfuUxeL64wzzBQ==} cpu: [ia32] os: [linux] + '@koromix/koffi-linux-loong64@3.2.1': + resolution: {integrity: sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==} + cpu: [loong64] + os: [linux] + '@koromix/koffi-linux-loong64@3.3.6': resolution: {integrity: sha512-m33Zjrhj2Atpkcr0We61V2Zjm8wFb79QOtaOfzttmNFQMtryYpWZ4CBxx4gLKvcrR7NcK3GV/D9Qi2M0ppWK7A==} cpu: [loong64] @@ -4809,11 +4922,21 @@ packages: cpu: [ppc64] os: [linux] + '@koromix/koffi-linux-riscv64@3.2.1': + resolution: {integrity: sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==} + cpu: [riscv64] + os: [linux] + '@koromix/koffi-linux-riscv64@3.3.6': resolution: {integrity: sha512-VnzoNRg3MfTdGXLDtUrBj4wJnZD3buX6LajGrvYm5Mf1U132bzQwxP1ryZhs8Q0Cl1pTsN2fIovjNwpwE7Hs3A==} cpu: [riscv64] os: [linux] + '@koromix/koffi-linux-x64@3.2.1': + resolution: {integrity: sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==} + cpu: [x64] + os: [linux] + '@koromix/koffi-linux-x64@3.3.6': resolution: {integrity: sha512-RTRbVTZ1rf1GFowSsbuU2acYoRq66ZwJGh+rTgau1Cat3muv2884p0FRH6UKfwlgWkpjIHJ7N8N0liM7Oe2eAQ==} cpu: [x64] @@ -4824,26 +4947,51 @@ packages: cpu: [arm64] os: [openbsd] + '@koromix/koffi-openbsd-ia32@3.2.1': + resolution: {integrity: sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==} + cpu: [ia32] + os: [openbsd] + '@koromix/koffi-openbsd-ia32@3.3.6': resolution: {integrity: sha512-xbJYOFY0IAIx3nyNdYad5g2f8cNAOY8OBcjPDYRlc7gkqQiPNER9ZA9g+M2LuVf48i65ZCn11XaWDSQymDJJBg==} cpu: [ia32] os: [openbsd] + '@koromix/koffi-openbsd-x64@3.2.1': + resolution: {integrity: sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==} + cpu: [x64] + os: [openbsd] + '@koromix/koffi-openbsd-x64@3.3.6': resolution: {integrity: sha512-bNL4uIxUBhA93naaL3dWwXdLgQGAss6krDbwljO1uS3iJ+8K7KSo8EGbJfRYUf1L+ctaHTJuDwgZ1XTSEC9FQQ==} cpu: [x64] os: [openbsd] + '@koromix/koffi-win32-arm64@3.2.1': + resolution: {integrity: sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==} + cpu: [arm64] + os: [win32] + '@koromix/koffi-win32-arm64@3.3.6': resolution: {integrity: sha512-WDVsrvWAaHbA7xiCMXX3PBmfDf4+VnfY67ryRAW86QNW8109W9WPS3YVGGD2YoPswDmSZsMBVzzZCEFUrLdJuA==} cpu: [arm64] os: [win32] + '@koromix/koffi-win32-ia32@3.2.1': + resolution: {integrity: sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==} + cpu: [ia32] + os: [win32] + '@koromix/koffi-win32-ia32@3.3.6': resolution: {integrity: sha512-6hwsUGC7FCICiLdmEQwTK0+MOLmuraqWuVsUCBkdSCL6k62LQLScuTn2dzONFvN/H5EXq0zzX4ddgtq2abL/xQ==} cpu: [ia32] os: [win32] + '@koromix/koffi-win32-x64@3.2.1': + resolution: {integrity: sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==} + cpu: [x64] + os: [win32] + '@koromix/koffi-win32-x64@3.3.6': resolution: {integrity: sha512-g9KC6VKQLe88Tjgeubk3yhu0CjtqSeRq4XjWFWxUq4rsP0/wfLUBoeISvG60HoPmhYQp+U7hVd8Ms+amOMHDvA==} cpu: [x64] @@ -9736,6 +9884,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + koffi@3.2.1: + resolution: {integrity: sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==} + koffi@3.3.6: resolution: {integrity: sha512-pPqCZg8o4laLvzxySGEju/T1Fd+eCJTVnJmcH6h1u98HFRoBQ9EokNuBSj3NxIQdzkEPRxPnHKjhBVkLeB+X0g==} @@ -12190,6 +12341,10 @@ packages: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + vscode-languageserver-protocol@3.17.5: resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} @@ -12435,6 +12590,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -14577,6 +14735,12 @@ snapshots: '@fontsource-variable/inter@5.3.0': {} + '@github/copilot-sdk@1.0.28': + dependencies: + koffi: 3.2.1 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + '@handy-computer/models@0.2.3': {} '@hono/node-server@1.19.17(hono@4.13.7)': @@ -14841,63 +15005,117 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@koromix/koffi-android-arm64@3.2.1': + optional: true + '@koromix/koffi-android-arm64@3.3.6': optional: true + '@koromix/koffi-android-x64@3.2.1': + optional: true + '@koromix/koffi-android-x64@3.3.6': optional: true + '@koromix/koffi-darwin-arm64@3.2.1': + optional: true + '@koromix/koffi-darwin-arm64@3.3.6': optional: true + '@koromix/koffi-darwin-x64@3.2.1': + optional: true + '@koromix/koffi-darwin-x64@3.3.6': optional: true + '@koromix/koffi-freebsd-arm64@3.2.1': + optional: true + '@koromix/koffi-freebsd-arm64@3.3.6': optional: true + '@koromix/koffi-freebsd-ia32@3.2.1': + optional: true + '@koromix/koffi-freebsd-ia32@3.3.6': optional: true + '@koromix/koffi-freebsd-x64@3.2.1': + optional: true + '@koromix/koffi-freebsd-x64@3.3.6': optional: true + '@koromix/koffi-linux-arm64@3.2.1': + optional: true + '@koromix/koffi-linux-arm64@3.3.6': optional: true + '@koromix/koffi-linux-arm@3.2.1': + optional: true + '@koromix/koffi-linux-arm@3.3.6': optional: true + '@koromix/koffi-linux-ia32@3.2.1': + optional: true + '@koromix/koffi-linux-ia32@3.3.6': optional: true + '@koromix/koffi-linux-loong64@3.2.1': + optional: true + '@koromix/koffi-linux-loong64@3.3.6': optional: true '@koromix/koffi-linux-ppc64@3.3.6': optional: true + '@koromix/koffi-linux-riscv64@3.2.1': + optional: true + '@koromix/koffi-linux-riscv64@3.3.6': optional: true + '@koromix/koffi-linux-x64@3.2.1': + optional: true + '@koromix/koffi-linux-x64@3.3.6': optional: true '@koromix/koffi-openbsd-arm64@3.3.6': optional: true + '@koromix/koffi-openbsd-ia32@3.2.1': + optional: true + '@koromix/koffi-openbsd-ia32@3.3.6': optional: true + '@koromix/koffi-openbsd-x64@3.2.1': + optional: true + '@koromix/koffi-openbsd-x64@3.3.6': optional: true + '@koromix/koffi-win32-arm64@3.2.1': + optional: true + '@koromix/koffi-win32-arm64@3.3.6': optional: true + '@koromix/koffi-win32-ia32@3.2.1': + optional: true + '@koromix/koffi-win32-ia32@3.3.6': optional: true + '@koromix/koffi-win32-x64@3.2.1': + optional: true + '@koromix/koffi-win32-x64@3.3.6': optional: true @@ -19566,6 +19784,27 @@ snapshots: yaml: 2.9.0 zod: 4.6.5 + koffi@3.2.1: + optionalDependencies: + '@koromix/koffi-android-arm64': 3.2.1 + '@koromix/koffi-android-x64': 3.2.1 + '@koromix/koffi-darwin-arm64': 3.2.1 + '@koromix/koffi-darwin-x64': 3.2.1 + '@koromix/koffi-freebsd-arm64': 3.2.1 + '@koromix/koffi-freebsd-ia32': 3.2.1 + '@koromix/koffi-freebsd-x64': 3.2.1 + '@koromix/koffi-linux-arm': 3.2.1 + '@koromix/koffi-linux-arm64': 3.2.1 + '@koromix/koffi-linux-ia32': 3.2.1 + '@koromix/koffi-linux-loong64': 3.2.1 + '@koromix/koffi-linux-riscv64': 3.2.1 + '@koromix/koffi-linux-x64': 3.2.1 + '@koromix/koffi-openbsd-ia32': 3.2.1 + '@koromix/koffi-openbsd-x64': 3.2.1 + '@koromix/koffi-win32-arm64': 3.2.1 + '@koromix/koffi-win32-ia32': 3.2.1 + '@koromix/koffi-win32-x64': 3.2.1 + koffi@3.3.6: optionalDependencies: '@koromix/koffi-android-arm64': 3.3.6 @@ -22694,6 +22933,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 @@ -22933,6 +23174,8 @@ snapshots: zod@3.25.76: {} + zod@4.3.6: {} + zod@4.5.4: {} zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95d8b2d57e09f..75f9aa8602e6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -146,6 +146,15 @@ overrides: "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl": "-" "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64": "-" "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64": "-" + # The SDK always receives the Copilot executable T3 resolved, so its bundled runtimes are unused. + "@github/copilot-sdk>@github/copilot-sdk-darwin-arm64": "-" + "@github/copilot-sdk>@github/copilot-sdk-darwin-x64": "-" + "@github/copilot-sdk>@github/copilot-sdk-linux-arm64": "-" + "@github/copilot-sdk>@github/copilot-sdk-linux-x64": "-" + "@github/copilot-sdk>@github/copilot-sdk-linuxmusl-arm64": "-" + "@github/copilot-sdk>@github/copilot-sdk-linuxmusl-x64": "-" + "@github/copilot-sdk>@github/copilot-sdk-win32-arm64": "-" + "@github/copilot-sdk>@github/copilot-sdk-win32-x64": "-" "@clerk/backend": "catalog:" "@clerk/clerk-js": "catalog:" "@clerk/clerk-js>@base-org/account": "-" diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index a586d67ef6035..1234528113e3c 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-cursor/package.json", "packages/provider-fx/package.json", "packages/provider-grok/package.json", + "packages/provider-copilot/package.json", "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index e6d38a17e2d87..5fcd785d9f545 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -229,6 +229,15 @@ } ], "packageOverrides": [ + { + "name": "@github/copilot-sdk", + "version": "1.0.28", + "sourceUrl": "https://github.com/github/copilot-sdk", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright GitHub, Inc."] + } + }, { "name": "entities", "version": "8.1.0",