diff --git a/apps/server/package.json b/apps/server/package.json index 8ef9784ba7f..eea1571d486 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,6 +27,8 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.60", + "@github/copilot-sdk": "1.0.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 778956e5206..1f6a44a45e1 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -202,5 +202,43 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { expect(whitespaceIgnoredDiff).not.toContain("+

Title

"); }), ); + + it.effect("returns a concise unavailable error when the target checkpoint ref is missing", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-missing-target"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(path.join(tmp, "README.md"), "changed\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + yield* git(tmp, ["update-ref", "-d", toCheckpointRef]); + + const error = yield* checkpointStore + .diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: true, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "VcsProcessExitError", + command: "git diff", + detail: "Checkpoint ref is unavailable for diff operation.", + }); + expect(error.message).not.toContain("ambiguous argument"); + }), + ); }); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 3c9ec2cdfa5..d78f0d726cc 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -54,9 +54,11 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -80,11 +82,12 @@ function createProviderServiceHarness( hasSession = true, sessionCwd = cwd, providerName: ProviderSession["provider"] = ProviderDriverKind.make("codex"), + rollbackConversationImpl?: ProviderServiceShape["rollbackConversation"], ) { const now = "2026-01-01T00:00:00.000Z"; const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const rollbackConversation = vi.fn( - (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, + const rollbackConversation = vi.fn( + rollbackConversationImpl ?? (() => Effect.void), ); const unsupported = () => @@ -247,7 +250,11 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) describe("CheckpointReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery, + | OrchestrationEngineService + | CheckpointReactor + | CheckpointStore + | ProjectionSnapshotQuery + | WorkspaceEntries, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -277,6 +284,7 @@ describe("CheckpointReactor", () => { readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; + readonly rollbackConversation?: ProviderServiceShape["rollbackConversation"]; readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); @@ -286,6 +294,7 @@ describe("CheckpointReactor", () => { options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, options?.providerName ?? ProviderDriverKind.make("codex"), + options?.rollbackConversation, ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -346,6 +355,7 @@ describe("CheckpointReactor", () => { const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + const workspaceEntries = await runtime.runPromise(Effect.service(WorkspaceEntries)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -411,6 +421,8 @@ describe("CheckpointReactor", () => { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, + checkpointStore, + workspaceEntries, cwd, drain, }; @@ -965,6 +977,81 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("does not roll back provider conversation when filesystem restore fails", async () => { + const harness = await createHarness(); + const createdAt = "2026-01-01T00:00:00.000Z"; + vi.spyOn(harness.checkpointStore, "restoreCheckpoint").mockImplementationOnce(() => + Effect.succeed(false), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-restore-fails"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-restore-fails-diff-1"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-restore-fails-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-restore-fails-diff-2"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-restore-fails-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-restore-fails"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + }); + it("executes provider revert and emits thread.reverted for claude sessions", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); const createdAt = "2026-01-01T00:00:00.000Z"; @@ -1034,11 +1121,191 @@ describe("CheckpointReactor", () => { }); }); + it("restores current checkpoint files when provider rollback is unsupported", async () => { + const harness = await createHarness({ + providerName: ProviderDriverKind.make("copilot"), + rollbackConversation: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "copilot", + method: "thread.rollback", + detail: "Copilot SDK does not expose thread rollback.", + }), + ), + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-copilot"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "copilot", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-diff-copilot-1"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-copilot-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-diff-copilot-2"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-copilot-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-copilot-request"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), + ).toBe(true); + }); + + it("invalidates workspace entries when provider rollback and recovery restore fail", async () => { + const harness = await createHarness({ + providerName: ProviderDriverKind.make("copilot"), + rollbackConversation: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "copilot", + method: "thread.rollback", + detail: "Copilot SDK does not expose thread rollback.", + }), + ), + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + const invalidate = vi + .spyOn(harness.workspaceEntries, "invalidate") + .mockImplementation(() => Effect.void); + const restoreCheckpoint = harness.checkpointStore.restoreCheckpoint; + let restoreCalls = 0; + vi.spyOn(harness.checkpointStore, "restoreCheckpoint").mockImplementation((input) => { + restoreCalls += 1; + if (restoreCalls === 2) { + return Effect.succeed(false); + } + return restoreCheckpoint(input); + }); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-recovery-restore-fails"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "copilot", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-recovery-restore-fails-diff-1"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-recovery-restore-fails-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-recovery-restore-fails-diff-2"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-recovery-restore-fails-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-recovery-restore-fails"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(restoreCalls).toBe(2); + expect(invalidate).toHaveBeenCalledWith(harness.cwd); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-inline-revert"), @@ -1056,7 +1323,7 @@ describe("CheckpointReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make("cmd-inline-revert-diff-1"), @@ -1070,7 +1337,7 @@ describe("CheckpointReactor", () => { createdAt, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make("cmd-inline-revert-diff-2"), @@ -1085,7 +1352,7 @@ describe("CheckpointReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-sequenced-revert-request-1"), @@ -1094,7 +1361,7 @@ describe("CheckpointReactor", () => { createdAt, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-sequenced-revert-request-0"), @@ -1121,7 +1388,7 @@ describe("CheckpointReactor", () => { const harness = await createHarness({ hasSession: false }); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-revert-no-session"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 40291ec4f66..defc180bc54 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -675,6 +675,31 @@ const make = Effect.gen(function* () { return; } + const targetCheckpointAvailable = + event.payload.turnCount === 0 + ? true + : yield* checkpointStore.hasCheckpointRef({ + cwd: sessionRuntime.value.cwd, + checkpointRef: targetCheckpointRef, + }); + if (!targetCheckpointAvailable) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const currentCheckpointRef = + currentTurnCount === 0 + ? checkpointRefForThreadTurn(event.payload.threadId, 0) + : thread.checkpoints.find( + (checkpoint) => checkpoint.checkpointTurnCount === currentTurnCount, + )?.checkpointRef; + + const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); const restored = yield* checkpointStore.restoreCheckpoint({ cwd: sessionRuntime.value.cwd, checkpointRef: targetCheckpointRef, @@ -690,18 +715,57 @@ const make = Effect.gen(function* () { return; } + const rollbackFailureDetail: string | null = + rolledBackTurns > 0 + ? yield* providerService + .rollbackConversation({ + threadId: sessionRuntime.value.threadId, + numTurns: rolledBackTurns, + }) + .pipe( + Effect.as(null), + Effect.catch((error) => + Effect.gen(function* () { + const detail = currentCheckpointRef + ? yield* checkpointStore + .restoreCheckpoint({ + cwd: sessionRuntime.value.cwd, + checkpointRef: currentCheckpointRef, + fallbackToHead: currentTurnCount === 0, + }) + .pipe( + Effect.map((restoredCurrent) => + restoredCurrent + ? `Provider rollback failed after filesystem restore: ${error.message}. Filesystem was restored to the current checkpoint.` + : `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint.`, + ), + Effect.catch((restoreError) => + Effect.succeed( + `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint: ${restoreError.message}`, + ), + ), + ) + : `Provider rollback failed after filesystem restore: ${error.message}. Current checkpoint ref is unavailable.`; + yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); + return detail; + }), + ), + ) + : null; + if (rollbackFailureDetail !== null) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: rollbackFailureDetail, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + // Invalidate the workspace entry cache so the @-mention file picker // reflects the reverted filesystem state. yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); - const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); - if (rolledBackTurns > 0) { - yield* providerService.rollbackConversation({ - threadId: sessionRuntime.value.threadId, - numTurns: rolledBackTurns, - }); - } - const staleCheckpointRefs: Array = []; for (const checkpoint of thread.checkpoints) { if (checkpoint.checkpointTurnCount > event.payload.turnCount) { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..debc4b45c74 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -755,9 +755,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + const session = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const anotherTurnStillRunning = + Option.isSome(session) && + session.value.status === "running" && + session.value.activeTurnId !== null && + session.value.activeTurnId !== event.payload.turnId; yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: event.payload.turnId, + latestTurnId: anotherTurnStillRunning + ? existingRow.value.latestTurnId + : event.payload.turnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 77f9a2ed904..9a85b37b893 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,6 +22,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -143,6 +144,7 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; + readonly textGenerationModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; }) { @@ -308,7 +310,13 @@ describe("ProviderCommandReactor", () => { getInstanceInfo: (instanceId) => { const raw = String(instanceId); const driverKind = ProviderDriverKind.make( - raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, + raw.startsWith("claude") + ? "claudeAgent" + : raw.startsWith("codex") + ? "codex" + : raw.startsWith("copilot") + ? "copilot" + : raw, ); return Effect.succeed({ instanceId, @@ -367,7 +375,13 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerSettingsService.layerTest( + input?.textGenerationModelSelection !== undefined + ? { textGenerationModelSelection: input.textGenerationModelSelection } + : {}, + ), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -515,6 +529,163 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + it("waits for first-turn thread title generation when draining", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + const releaseTitleGeneration = await runtime!.runPromise(Deferred.make()); + harness.generateThreadTitle.mockReturnValue( + Deferred.await(releaseTitleGeneration).pipe( + Effect.as({ + title: "Generated title", + }), + ), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-drain-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-drain"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-drain"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + let drained = false; + const drainPromise = harness.drain().then(() => { + drained = true; + }); + await runtime!.runPromise(Effect.yieldNow); + expect(drained).toBe(false); + + await runtime!.runPromise( + Deferred.succeed(releaseTitleGeneration, undefined).pipe(Effect.orDie), + ); + await drainPromise; + + expect(harness.sendTurn).toHaveBeenCalledOnce(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Generated title"); + }); + + it("retries transient first-turn thread title generation failures", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + harness.generateThreadTitle + .mockReturnValueOnce( + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "transient provider error", + }), + ), + ) + .mockReturnValueOnce(Effect.succeed({ title: "Generated after retry" })); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-retry-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-retry"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-retry"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 2); + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Generated after retry"); + }); + + it("generates a Copilot thread title while starting the first Copilot turn", async () => { + const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + const harness = await createHarness({ + threadModelSelection: copilotSelection, + textGenerationModelSelection: copilotSelection, + }); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Investigate Copilot thread startup"; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-copilot-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-copilot-title-skip"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-copilot-title-skip"), + role: "user", + text: "Investigate Copilot thread startup errors.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledOnce(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Generated title"); + }); + it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -606,6 +777,59 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Reconnect spinner resume bug"); }); + it("replaces provider-expanded first prompt titles that match the truncated client seed", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const fullPromptTitle = + "Please investigate reconnect failures after restarting the session and make the startup path reliable."; + const seededTitle = "Please investigate reconnect failures after restar..."; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ + title: "Reconnect startup reliability", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-provider-expanded-prompt"), + threadId: ThreadId.make("thread-1"), + title: fullPromptTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-provider-expanded-prompt"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-provider-expanded-prompt"), + role: "user", + text: fullPromptTitle, + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "Reconnect startup reliability" + ); + }); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Reconnect startup reliability"); + }); + it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -660,6 +884,65 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); + it("generates a worktree branch name for the first Copilot turn using Copilot text generation", async () => { + const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + const harness = await createHarness({ + threadModelSelection: copilotSelection, + textGenerationModelSelection: copilotSelection, + }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-branch-copilot"), + threadId: ThreadId.make("thread-1"), + branch: "t3code/c0ffee42", + worktreePath: "/tmp/provider-copilot-worktree", + }), + ); + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.branch === "t3code/c0ffee42" && + thread.worktreePath === "/tmp/provider-copilot-worktree" + ); + }); + + harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: "copilot-provider" })); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-copilot-branch-model"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-copilot-branch-model"), + role: "user", + text: "Add Copilot branch naming.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-copilot-worktree", + message: "Add Copilot branch naming.", + modelSelection: copilotSelection, + }); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-copilot-worktree", + oldBranch: "t3code/c0ffee42", + newBranch: "t3code/copilot-provider", + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1974,7 +2257,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2007,7 +2290,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), @@ -2056,7 +2339,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-stop"), @@ -2075,7 +2358,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-session-stop"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..0373a8694ae 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,6 +13,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { truncate } from "@t3tools/shared/String"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -110,9 +111,13 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea } const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; + if (trimmedTitleSeed === undefined || trimmedTitleSeed.length === 0) { + return false; + } + + return ( + trimmedCurrentTitle === trimmedTitleSeed || truncate(trimmedCurrentTitle) === trimmedTitleSeed + ); } function findProviderAdapterRequestError( @@ -666,7 +671,6 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, @@ -702,6 +706,7 @@ const make = Effect.gen(function* () { const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( function* (input: { readonly threadId: ThreadId; + readonly threadModelSelection: ModelSelection; readonly cwd: string; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -711,13 +716,14 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: input.cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); + const generated = yield* Effect.suspend(() => + textGeneration.generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }), + ).pipe(Effect.retry({ times: 2 })); if (!generated) return; const thread = yield* resolveThread(input.threadId); @@ -785,20 +791,25 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ + yield* firstTurnAuxiliaryWorker.enqueue( + maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + threadModelSelection: thread.modelSelection, + cwd: generationCwd, + ...generationInput, + }), + ); + } + + yield* firstTurnAuxiliaryWorker.enqueue( + maybeGenerateAndRenameWorktreeBranchForFirstTurn({ threadId: event.payload.threadId, - cwd: generationCwd, + branch: thread.branch, + worktreePath: thread.worktreePath, ...generationInput, - }).pipe(Effect.forkScoped); - } + }), + ); } const handleTurnStartFailure = (cause: Cause.Cause) => { @@ -1058,6 +1069,7 @@ const make = Effect.gen(function* () { }), ); + const firstTurnAuxiliaryWorker = yield* makeDrainableWorker((job: Effect.Effect) => job); const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { @@ -1081,7 +1093,7 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: worker.drain.pipe(Effect.andThen(firstTurnAuxiliaryWorker.drain), Effect.asVoid), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 64955590235..cb014631531 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2665,6 +2665,133 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("projects reasoning text deltas into normalized thread activities", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-reasoning-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-reasoning"), + itemId: asItemId("item-reasoning"), + payload: { + streamKind: "reasoning_text", + delta: "Thinking through the implementation", + contentIndex: 0, + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-reasoning-summary-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-reasoning"), + itemId: asItemId("item-reasoning-summary"), + payload: { + streamKind: "reasoning_summary_text", + delta: "Implementation summary", + summaryIndex: 1, + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "reasoning.update", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "reasoning.summary", + ), + ); + + const reasoning = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-reasoning-delta", + ); + expect(reasoning?.kind).toBe("reasoning.update"); + expect(reasoning?.payload).toMatchObject({ + detail: "Thinking through the implementation", + streamKind: "reasoning_text", + contentIndex: 0, + }); + + const summary = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-reasoning-summary-delta", + ); + expect(summary?.kind).toBe("reasoning.summary"); + expect(summary?.payload).toMatchObject({ + detail: "Implementation summary", + streamKind: "reasoning_summary_text", + summaryIndex: 1, + }); + }); + + it("projects provider tool output deltas into normalized thread activities", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-command-output-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-output"), + itemId: asItemId("item-command"), + payload: { + streamKind: "command_output", + delta: "stdout: tests passed", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-file-output-delta"), + provider: ProviderDriverKind.make("copilot"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-output"), + itemId: asItemId("item-file"), + payload: { + streamKind: "file_change_output", + delta: "updated README.md", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.activities.filter( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.output", + ).length >= 2, + ); + + const commandOutput = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-output-delta", + ); + expect(commandOutput?.kind).toBe("tool.output"); + expect(commandOutput?.summary).toBe("Command output"); + expect(commandOutput?.payload).toMatchObject({ + detail: "stdout: tests passed", + streamKind: "command_output", + itemId: "item-command", + }); + + const fileOutput = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-file-output-delta", + ); + expect(fileOutput?.kind).toBe("tool.output"); + expect(fileOutput?.summary).toBe("File change output"); + expect(fileOutput?.payload).toMatchObject({ + detail: "updated README.md", + streamKind: "file_change_output", + itemId: "item-file", + }); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..eb177065bb7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -554,6 +554,73 @@ function runtimeEventToActivities( ]; } + case "content.delta": { + if (event.payload.delta.trim().length === 0) { + return []; + } + if ( + event.payload.streamKind === "command_output" || + event.payload.streamKind === "file_change_output" || + event.payload.streamKind === "unknown" + ) { + const summary = + event.payload.streamKind === "command_output" + ? "Command output" + : event.payload.streamKind === "file_change_output" + ? "File change output" + : "Tool output"; + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "tool", + kind: "tool.output", + summary, + payload: { + detail: truncateDetail(event.payload.delta), + streamKind: event.payload.streamKind, + ...(event.itemId ? { itemId: event.itemId } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + if ( + event.payload.streamKind !== "reasoning_text" && + event.payload.streamKind !== "reasoning_summary_text" + ) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: + event.payload.streamKind === "reasoning_summary_text" + ? "reasoning.summary" + : "reasoning.update", + summary: + event.payload.streamKind === "reasoning_summary_text" + ? "Reasoning summary" + : "Reasoning update", + payload: { + detail: truncateDetail(event.payload.delta), + streamKind: event.payload.streamKind, + ...(event.payload.contentIndex !== undefined + ? { contentIndex: event.payload.contentIndex } + : {}), + ...(event.payload.summaryIndex !== undefined + ? { summaryIndex: event.payload.summaryIndex } + : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "item.updated": { if (!isToolLifecycleItemType(event.payload.itemType)) { return []; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..327fc9250fe 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -584,28 +584,35 @@ export function projectEvent( // Mid-turn diff updates produce placeholder checkpoints; record the // checkpoint, but don't settle a turn its session is still running. const turnStillRunning = - thread.session?.status === "running" && thread.session.activeTurnId === payload.turnId; + thread.session?.status === "running" && + thread.session.activeTurnId !== null && + thread.session.activeTurnId === payload.turnId; + const anotherTurnStillRunning = + thread.session?.status === "running" && + thread.session.activeTurnId !== null && + thread.session.activeTurnId !== payload.turnId; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, - latestTurn: turnStillRunning - ? thread.latestTurn - : { - turnId: payload.turnId, - state: checkpointStatusToLatestTurnState(payload.status), - requestedAt: - thread.latestTurn?.turnId === payload.turnId - ? thread.latestTurn.requestedAt - : payload.completedAt, - startedAt: - thread.latestTurn?.turnId === payload.turnId - ? (thread.latestTurn.startedAt ?? payload.completedAt) - : payload.completedAt, - completedAt: payload.completedAt, - assistantMessageId: payload.assistantMessageId, - }, + latestTurn: + turnStillRunning || anotherTurnStillRunning + ? thread.latestTurn + : { + turnId: payload.turnId, + state: checkpointStatusToLatestTurnState(payload.status), + requestedAt: + thread.latestTurn?.turnId === payload.turnId + ? thread.latestTurn.requestedAt + : payload.completedAt, + startedAt: + thread.latestTurn?.turnId === payload.turnId + ? (thread.latestTurn.startedAt ?? payload.completedAt) + : payload.completedAt, + completedAt: payload.completedAt, + assistantMessageId: payload.assistantMessageId, + }, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 00000000000..05bbd60ac77 --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,154 @@ +/** + * CopilotDriver — `ProviderDriver` for the GitHub Copilot SDK runtime. + * + * Mirrors the other provider drivers: a plain value whose `create()` returns + * one `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration` + * closures captured over the per-instance `CopilotSettings`. + * + * Each instance owns an isolated Copilot home directory under server state and + * a per-instance SDK client, so sessions and persisted cursors do not leak + * across configured Copilot providers. + * + * @module provider/Drivers/CopilotDriver + */ +import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, Path, Schema, Stream } from "effect"; +import * as FileSystem from "effect/FileSystem"; + +import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCopilotAdapter } from "../Layers/CopilotAdapter.ts"; +import { + checkCopilotProviderStatus, + makePendingCopilotProvider, +} from "../Layers/CopilotProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("copilot"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.hours(1); +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); + +export type CopilotDriverEnv = + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "GitHub Copilot", + supportsMultipleInstances: true, + }, + configSchema: CopilotSettings, + defaultConfig: (): CopilotSettings => decodeCopilotSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const eventLoggers = yield* ProviderEventLoggers; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies CopilotSettings; + const baseDirectory = path.join(serverConfig.stateDir, "providers", "copilot", instanceId); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "@github/copilot-sdk", + }); + yield* fileSystem.makeDirectory(baseDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to prepare Copilot home: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + const adapter = yield* makeCopilotAdapter(effectiveConfig, { + instanceId, + baseDirectory, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv, { + baseDirectory, + }); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + Effect.succeed(stampIdentity(makePendingCopilotProvider(settings))), + checkProvider: checkCopilotProviderStatus({ + settings: effectiveConfig, + cwd: serverConfig.cwd, + baseDirectory, + environment: processEnv, + }).pipe(Effect.map(stampIdentity)), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Copilot snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts new file mode 100644 index 00000000000..f9381d42f54 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,2790 @@ +import assert from "node:assert/strict"; +import { setTimeout as sleep } from "node:timers/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { + CopilotClient, + CopilotSession, + PermissionRequest, + SessionConfig, + SessionEvent, +} from "@github/copilot-sdk"; +import { beforeEach, it } from "@effect/vitest"; +import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; +import { vi } from "vite-plus/test"; + +import { + ApprovalRequestId, + CopilotSettings, + type ProviderRuntimeEvent, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import type { CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { makeCopilotAdapter } from "./CopilotAdapter.ts"; + +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); + +class CopilotAdapter extends Context.Service()( + "t3/provider/Layers/CopilotAdapter.test/CopilotAdapter", +) {} + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const COPILOT_DRIVER = ProviderDriverKind.make("copilot"); +const COPILOT_INSTANCE_ID = ProviderInstanceId.make("copilot"); +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const waitForSdkEventQueue = () => Effect.promise(() => sleep(10).then(() => undefined)); + +const runtimeMock = vi.hoisted(() => { + const makeSession = () => ({ + sessionId: "copilot-sdk-session-1", + rpc: { + mode: { + set: vi.fn(async () => undefined), + }, + plan: { + read: vi.fn(async () => ({ content: "" })), + }, + backgroundTasks: { + list: vi.fn( + async (): Promise<{ tasks: Array> }> => ({ + tasks: [], + }), + ), + }, + }, + disconnect: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + send: vi.fn(async () => undefined), + abort: vi.fn(async () => undefined), + }); + + const state = { + startCalls: 0, + stopCalls: 0, + nativeWriteCalls: 0, + nativeWriteGate: null as Promise | null, + createSessionConfigs: [] as SessionConfig[], + resumeSessionCalls: [] as Array<{ readonly sessionId: string; readonly config: SessionConfig }>, + createSessionImpl: null as ((config: SessionConfig) => Promise) | null, + resumeSessionImpl: null as + | ((sessionId: string, config: SessionConfig) => Promise) + | null, + lastSession: makeSession(), + }; + + return { + state, + reset() { + state.startCalls = 0; + state.stopCalls = 0; + state.nativeWriteCalls = 0; + state.nativeWriteGate = null; + state.createSessionConfigs.length = 0; + state.resumeSessionCalls.length = 0; + state.lastSession = makeSession(); + state.createSessionImpl = async () => state.lastSession as unknown as CopilotSession; + state.resumeSessionImpl = async () => state.lastSession as unknown as CopilotSession; + }, + }; +}); + +vi.mock("../copilotRuntime.ts", async () => { + const actual = + await vi.importActual("../copilotRuntime.ts"); + + return { + ...actual, + createCopilotClient: vi.fn(() => + Effect.succeed({ + start: vi.fn(async () => { + runtimeMock.state.startCalls += 1; + }), + stop: vi.fn(async () => { + runtimeMock.state.stopCalls += 1; + }), + createSession: vi.fn(async (config: SessionConfig) => { + runtimeMock.state.createSessionConfigs.push(config); + return (runtimeMock.state.createSessionImpl ?? (async () => undefined as never))(config); + }), + resumeSession: vi.fn(async (sessionId: string, config: SessionConfig) => { + runtimeMock.state.resumeSessionCalls.push({ sessionId, config }); + return (runtimeMock.state.resumeSessionImpl ?? (async () => undefined as never))( + sessionId, + config, + ); + }), + } as unknown as CopilotClient), + ), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const nativeEventLogger = { + filePath: "memory://copilot-native-events.ndjson", + write: vi.fn(() => + Effect.promise(async () => { + runtimeMock.state.nativeWriteCalls += 1; + const gate = runtimeMock.state.nativeWriteGate; + if (gate) { + await gate; + } + }), + ), + close: vi.fn(() => Effect.void), +} satisfies EventNdjsonLogger; + +const CopilotAdapterTestLayer = Layer.effect( + CopilotAdapter, + makeCopilotAdapter(decodeCopilotSettings({}), { nativeEventLogger }), +).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { + it.effect( + "denies bootstrap permission requests before the session context exists in approval-required mode", + () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + assert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + assert.deepStrictEqual(result, { kind: "reject" }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-permission-denied"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.equal(session.provider, "copilot"); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "approves bootstrap permission requests before the session context exists in full-access mode", + () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + assert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + assert.deepStrictEqual(result, { kind: "approve-once" }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-permission-approved"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "copilot"); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("only approves bootstrap edit permission requests in auto-accept-edits mode", () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + assert.ok(config.onPermissionRequest); + const shellResult = await config.onPermissionRequest( + { kind: "shell" } as PermissionRequest, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ); + const writeResult = await config.onPermissionRequest( + { kind: "write" } as PermissionRequest, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ); + assert.deepStrictEqual(shellResult, { kind: "reject" }); + assert.deepStrictEqual(writeResult, { kind: "approve-once" }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-auto-accept-edits"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + + assert.equal(session.provider, "copilot"); + assert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.at(-1), [ + { mode: "interactive" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "returns an empty bootstrap user input response before the session context exists", + () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + assert.ok(config.onUserInputRequest); + const response = await config.onUserInputRequest( + { + question: "How should Copilot continue?", + choices: ["Continue"], + allowFreeform: true, + }, + { sessionId: runtimeMock.state.lastSession.sessionId }, + ); + assert.deepStrictEqual(response, { + answer: "", + wasFreeform: true, + }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-user-input"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.equal(session.provider, "copilot"); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits canonical answer maps for completed Copilot user input", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-user-input-canonical-answers"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onUserInputRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "user-input-canonical-answer"; + const request = { + question: "How should Copilot continue?", + choices: ["Use default"], + allowFreeform: true, + }; + + const responsePromise = Promise.resolve( + config.onUserInputRequest(request, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + emit({ + id: "evt-copilot-user-input-requested", + timestamp, + parentId: null, + type: "user_input.requested", + data: { + requestId, + ...request, + }, + } as SessionEvent); + + let requested: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && requested === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + requested = runtimeEvents.find( + (event) => event.type === "user-input.requested" && String(event.requestId) === requestId, + ); + } + assert.equal(requested?.type, "user-input.requested"); + if (requested?.type === "user-input.requested") { + assert.equal(requested.providerRefs?.providerRequestId, requestId); + } + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { + answer: "Use a custom answer", + }); + const response = yield* Effect.promise(() => responsePromise); + assert.deepStrictEqual(response, { + answer: "Use a custom answer", + wasFreeform: true, + }); + + emit({ + id: "evt-copilot-user-input-completed", + timestamp, + parentId: null, + type: "user_input.completed", + data: { + requestId, + answer: response.answer, + wasFreeform: response.wasFreeform, + }, + } as SessionEvent); + + let resolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && resolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + resolved = runtimeEvents.find( + (event) => event.type === "user-input.resolved" && String(event.requestId) === requestId, + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(resolved?.type, "user-input.resolved"); + if (resolved?.type === "user-input.resolved") { + assert.equal(resolved.providerRefs?.providerRequestId, requestId); + assert.deepStrictEqual(resolved.payload.answers, { + answer: "Use a custom answer", + }); + assert.equal("wasFreeform" in resolved.payload.answers, false); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("passes selected Copilot context tier when creating a session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-start-session-context-tier"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "contextTier", value: "long_context" }, + ], + }, + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.equal(config?.model, "claude-sonnet-4.6"); + assert.equal(config?.reasoningEffort, "high"); + assert.equal(config?.contextTier, "long_context"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("starts a fresh session when the persisted Copilot resume cursor is missing", () => + Effect.gen(function* () { + runtimeMock.state.resumeSessionImpl = async (sessionId: string) => { + throw new Error( + `Request session.resume failed with message: Session not found: ${sessionId}`, + ); + }; + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stale-resume-cursor"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { + schemaVersion: 1, + sessionId: "missing-copilot-session", + }, + }); + + assert.equal(runtimeMock.state.resumeSessionCalls.length, 1); + assert.equal(runtimeMock.state.resumeSessionCalls[0]?.sessionId, "missing-copilot-session"); + assert.equal(runtimeMock.state.createSessionConfigs.length, 1); + assert.equal(runtimeMock.state.createSessionConfigs[0]?.sessionId, threadId); + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: runtimeMock.state.lastSession.sessionId, + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("passes selected Copilot context tier when changing models for a turn", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-turn-context-tier"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.setModel.mockClear(); + yield* adapter.sendTurn({ + threadId, + input: "Use the long context tier", + attachments: [], + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [{ id: "contextTier", value: "long_context" }], + }, + }); + + assert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls.at(-1), [ + "claude-sonnet-4.6", + { contextTier: "long_context" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns a session-scoped SDK approval for acceptForSession", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-permission-accept-for-session"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "shell", + toolCallId: "tool-shell-session-approval", + fullCommandText: "git status", + intention: "Check repository status", + commands: [{ identifier: "git", readOnly: true }], + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: true, + }; + const requestId = "permission-shell-session-approval"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-permission-session-approval", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: { + kind: "commands", + toolCallId: "tool-shell-session-approval", + fullCommandText: "git status", + intention: "Check repository status", + commandIdentifiers: ["git"], + canOfferSessionApproval: true, + }, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { + kind: "approve-for-session", + approval: { + kind: "commands", + commandIdentifiers: ["git"], + }, + }); + + let requestResolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && requestResolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + requestResolved = runtimeEvents.find( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + } + assert.equal(requestResolved?.type, "request.resolved"); + if (requestResolved?.type === "request.resolved") { + assert.equal(requestResolved.payload.requestType, "command_execution_approval"); + assert.equal(requestResolved.payload.decision, "acceptForSession"); + assert.deepStrictEqual(requestResolved.payload.resolution, result); + } + + config.onEvent({ + id: "evt-copilot-permission-session-approval-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + const resolvedEvents = runtimeEvents.filter( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + assert.equal(resolvedEvents.length, 1); + + const duplicateReply = yield* Effect.flip( + adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "acceptForSession"), + ); + assert.match(duplicateReply.message, /Unknown pending permission request/); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns a session-scoped SDK domain approval for URL acceptForSession", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-url-permission-accept-for-session"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "url", + toolCallId: "tool-url-session-approval", + url: "https://docs.github.com/en/copilot", + intention: "Fetch Copilot documentation", + }; + const requestId = "permission-url-session-approval"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-url-permission-session-approval", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { + kind: "approve-for-session", + domain: "docs.github.com", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "renders Copilot Task_complete tool output as assistant text when no assistant message arrives", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-task-complete-assistant-fallback"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "make an architecture diagram", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const resultText = + "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-1", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-task-complete", + toolName: "Task_complete", + arguments: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-task-complete", + success: true, + result: { + content: resultText, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + let thread = yield* adapter.readThread(threadId); + for ( + let attempt = 0; + attempt < 20 && + !thread.turns.some((entry) => + entry.items.some( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + thread = yield* adapter.readThread(threadId); + } + + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItem = turnSnapshot.items.find( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItem, { + type: "assistant_message", + messageId: `copilot-task-completion-${String(turn.turnId)}`, + content: resultText, + }); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const fallbackDelta = runtimeEvents.find( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + assert.equal(fallbackDelta?.type, "content.delta"); + if (fallbackDelta?.type === "content.delta") { + assert.equal( + String(fallbackDelta.itemId), + `copilot-task-completion-${String(turn.turnId)}`, + ); + assert.deepStrictEqual(fallbackDelta.payload, { + streamKind: "assistant_text", + delta: resultText, + }); + } + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "collab_agent_tool_call" && + String(event.itemId) === "copilot-tool-tool-task-complete", + ); + assert.equal(completedTool?.type, "item.completed"); + if (completedTool?.type === "item.completed") { + assert.equal(completedTool.providerRefs?.providerItemId, "tool-task-complete"); + } + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not render the file-change completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-file-change-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "edit the docs", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-file-change-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-file-change", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-edit-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-edit-file", + toolName: "edit_file", + arguments: { + path: "README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-edit-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-edit-file", + success: true, + result: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-file-change-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-file-change", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + assert.equal( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not render the generic tool completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-generic-tool-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "inspect the README", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-generic-tool-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-generic-tool", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-read-file", + toolName: "Read", + arguments: { + path: "README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-read-file", + success: true, + result: { + content: "# Project", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-generic-tool", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + assert.equal( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not render the command-only completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-only-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run the tests", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-only-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command-only", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-run-tests", + toolName: "bash", + arguments: { + command: "vp test", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-run-tests", + success: true, + result: { + content: "All tests passed.", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-only-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command-only", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not render the generic completion fallback after a task-completed result", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-generic-fallback-task-completed-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "delegate this task", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-generic-task-completed-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-generic-task-completed", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-task-completed-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-finish-work", + toolName: "finish_work", + arguments: { + description: "finish the work", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-task-completed-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-finish-work", + success: true, + result: { + content: "✓ Task completed: Updated the implementation.", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-task-completed-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-generic-task-completed", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.ok( + runtimeEvents.some( + (event) => + event.type === "item.completed" && + event.payload.itemType === "dynamic_tool_call" && + event.payload.detail === "✓ Task completed: Updated the implementation.", + ), + ); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not emit an empty turn diff when a Copilot file-change turn completes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-file-change-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "edit the docs", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-diff-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-diff", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-diff-edit-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-edit-file-diff", + toolName: "edit_file", + arguments: { + path: "README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-diff-edit-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-edit-file-diff", + success: true, + result: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-diff-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-diff", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits a file-change diff turn when a Copilot Apply_patch tool completes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-apply-patch-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "edit the docs", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; + + emit({ + id: "evt-copilot-apply-patch-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-apply-patch", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-apply-patch", + toolName: "Apply_patch", + arguments: { + patch, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-apply-patch", + success: true, + result: { + content: patch, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-apply-patch", + }, + } as SessionEvent); + + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal( + String(diffEvent.turnId), + `${String(turn.turnId)}:file-change:tool-apply-patch`, + ); + assert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: patch, + }); + } + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "file_change" && + String(event.itemId) === "copilot-tool-tool-apply-patch", + ); + assert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "does not emit a file-change diff turn when a Copilot command tool returns control output", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-turn-diff"); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command", + toolName: "bash", + arguments: { + command: "printf done > README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command", + success: true, + result: { + content: "", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + const parserErrorCalls = [ + ...consoleErrorSpy.mock.calls, + ...consoleLogSpy.mock.calls, + ].filter((args) => args.some((arg: unknown) => String(arg).includes("parseLineType"))); + assert.deepStrictEqual(parserErrorCalls, []); + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + String(event.itemId) === "copilot-tool-tool-command", + ); + assert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + } + }), + ); + + it.effect( + "emits a file-change diff turn when a Copilot command tool returns a unified diff", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-unified-diff-turn"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const diff = [ + "diff --git a/README.md b/README.md", + "index 1111111..2222222 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"); + + emit({ + id: "evt-copilot-command-diff-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command-diff", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command-diff", + toolName: "bash", + arguments: { + command: "git diff -- README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command-diff", + success: true, + result: { + content: diff, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command-diff", + }, + } as SessionEvent); + + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal( + String(diffEvent.turnId), + `${String(turn.turnId)}:file-change:tool-command-diff`, + ); + assert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not emit an empty turn diff when a Copilot write permission is approved", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-write-permission-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "update the README", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "permission-write-readme"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-write-readme", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as PermissionRequest; + + emit({ + id: "evt-copilot-write-permission-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + emit({ + id: "evt-copilot-write-permission-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + assert.equal(opened?.type, "request.opened"); + if (opened?.type === "request.opened") { + assert.equal(opened.payload.requestType, "file_change_approval"); + assert.equal(String(opened.turnId), String(turn.turnId)); + } + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const approvalResult = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + + emit({ + id: "evt-copilot-write-permission-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result: approvalResult, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-write-permission-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("prompts for shell permissions in auto-accept-edits mode", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-auto-accept-edits-shell-permission"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "shell", + toolCallId: "tool-shell-auto-accept-edits", + fullCommandText: "git status", + intention: "Check repository status", + commands: [{ identifier: "git", readOnly: true }], + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: true, + }; + const requestId = "permission-shell-auto-accept-edits"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-permission-auto-accept-edits", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: { + kind: "commands", + toolCallId: "tool-shell-auto-accept-edits", + fullCommandText: "git status", + intention: "Check repository status", + commandIdentifiers: ["git"], + canOfferSessionApproval: true, + }, + }, + } as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + assert.equal(opened?.type, "request.opened"); + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { kind: "approve-once" }); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits thread metadata updates from Copilot title changes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-title-change"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-title-change", + timestamp, + parentId: null, + type: "session.title_changed", + data: { + title: "Implement Copilot thread titles", + }, + } as SessionEvent); + + let titleEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && titleEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + titleEvent = runtimeEvents.find((event) => event.type === "thread.metadata.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(titleEvent?.type, "thread.metadata.updated"); + if (titleEvent?.type === "thread.metadata.updated") { + assert.equal(titleEvent.threadId, threadId); + assert.deepStrictEqual(titleEvent.payload, { + name: "Implement Copilot thread titles", + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits Copilot background tasks as task list plan updates", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-tasks-plan"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [ + { + type: "agent", + id: "task-explore-1", + toolCallId: "tool-task-explore-1", + description: "Exploring provider events", + status: "running", + startedAt: "2026-06-11T12:00:00.000Z", + agentType: "explore", + prompt: "Find Copilot task events", + }, + { + type: "shell", + id: "task-shell-1", + description: "Running tests", + status: "completed", + startedAt: "2026-06-11T12:00:01.000Z", + command: "vp test", + attachmentMode: "detached", + }, + { + type: "agent", + id: "task-review-1", + toolCallId: "tool-task-review-1", + description: "Reviewing implementation", + status: "failed", + startedAt: "2026-06-11T12:00:02.000Z", + agentType: "code-review", + prompt: "Review the implementation", + }, + ], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-background-tasks", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + + let planEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && planEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + planEvent = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(planEvent?.type, "turn.plan.updated"); + if (planEvent?.type === "turn.plan.updated") { + assert.equal(planEvent.threadId, threadId); + assert.equal(String(planEvent.turnId), String(turn.turnId)); + assert.deepStrictEqual(planEvent.payload, { + explanation: "Copilot Tasks", + plan: [ + { step: "Exploring provider events", status: "inProgress" }, + { step: "Running tests", status: "completed" }, + { step: "Reviewing implementation (failed)", status: "pending" }, + ], + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores empty Copilot background task plan updates", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-empty-background-tasks-plan"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-empty-background-tasks", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-empty-background-tasks-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-empty-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores background task change events when Copilot cannot list tasks", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-tasks-missing-list"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const session = runtimeMock.state.lastSession as unknown as { + rpc: { backgroundTasks?: unknown }; + }; + delete session.rpc.backgroundTasks; + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-background-tasks-without-list", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-background-tasks-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + assert.equal( + runtimeEvents.find((event) => event.type === "runtime.error"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits command metadata separately from command output", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-metadata"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "check git status", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command", + toolName: "bash", + arguments: { + command: "git status --short", + }, + }, + } as SessionEvent); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command", + success: true, + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find( + (event) => + event.type === "item.completed" && String(event.itemId) === "copilot-tool-tool-command", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.itemType, "command_execution"); + assert.equal(completed.payload.title, "Ran command"); + assert.equal( + completed.payload.detail, + "M apps/server/src/provider/Layers/CopilotAdapter.ts", + ); + assert.deepStrictEqual(completed.payload.data, { + toolCallId: "tool-command", + toolName: "bash", + command: "git status --short", + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores empty SDK tool progress messages without failing the session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-sdk-event-queue-recovers-after-handler-failure"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "finish even after a bad tool progress event", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-turn-start-after-bad-event", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-bad-event", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-bad-progress", + timestamp, + parentId: null, + type: "tool.execution_progress", + data: { + toolCallId: "tool-progress-bad", + progressMessage: null, + }, + } as unknown as SessionEvent); + emit({ + id: "evt-copilot-turn-end-after-bad-event", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-bad-event", + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find((event) => event.type === "turn.completed"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + const toolProgress = runtimeEvents.find((event) => event.type === "tool.progress"); + assert.equal(runtimeError, undefined); + assert.equal(toolProgress, undefined); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes the active turn as failed when Copilot reports a session error", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-session-error-completes-active-turn"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "trigger a provider session error", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-session-error-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-session-error", + }, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-session-error", + timestamp, + parentId: null, + type: "session.error", + data: { + message: "Copilot runtime crashed", + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal(runtimeError?.type, "runtime.error"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Copilot runtime crashed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits one canonical turn completion for duplicate Copilot lifecycle events", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-duplicate-lifecycle-completion"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "complete once", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-duplicate-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-turn-end-again", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + runtimeEvents.filter((event) => event.type === "turn.completed").length === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + assert.equal(completions.length, 1); + assert.equal(completions[0]?.type, "turn.completed"); + if (completions[0]?.type === "turn.completed") { + assert.equal(completions[0].payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not complete a queued turn from the previous Copilot idle event", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-queued-turn-idle-correlation"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + assert.equal( + runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ), + true, + ); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-queued-first-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* waitForSdkEventQueue(); + + const completionsAfterFirstIdle = runtimeEvents.filter( + (event) => event.type === "turn.completed", + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(firstTurn.turnId), + ).length, + 1, + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(secondTurn.turnId), + ).length, + 0, + ); + const latestEventAfterFirstIdle = runtimeEvents.at(-1); + assert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); + assert.equal(latestEventAfterFirstIdle.payload.state, "running"); + + emit({ + id: "evt-copilot-queued-second-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-queued-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-queued-second-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-queued-second", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ).length === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const secondCompletions = runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + assert.equal(secondCompletions.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("drains queued SDK events before disconnecting on stop", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-drains-event-chain"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "finish while stop is requested", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + let releaseNativeWrite: () => void = () => undefined; + runtimeMock.state.nativeWriteGate = new Promise((resolve) => { + releaseNativeWrite = resolve; + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-stop-drain-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-stop-drain", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stop-drain-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + for ( + let attempt = 0; + attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + const disconnectsBeforeDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; + releaseNativeWrite(); + yield* Fiber.join(stopFiber); + + for ( + let attempt = 0; + attempt < 20 && !runtimeEvents.some((event) => event.type === "turn.completed"); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(runtimeMock.state.nativeWriteCalls > 0, true); + assert.equal(disconnectsBeforeDrain, 0); + assert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); + assert.equal(runtimeMock.state.stopCalls, 1); + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }), + ); + + it.effect("completes the turn as failed when Copilot send rejects", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-failure-turn-completed"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.send.mockRejectedValueOnce(new Error("Copilot send rejected")); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const result = yield* adapter + .sendTurn({ + threadId, + input: "trigger send failure", + attachments: [], + }) + .pipe(Effect.result); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(result._tag, "Failure"); + const aborted = runtimeEvents.find((event) => event.type === "turn.aborted"); + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + + assert.equal(aborted?.type, "turn.aborted"); + assert.equal(completed?.type, "turn.completed"); + if (aborted?.type === "turn.aborted" && completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(aborted.turnId)); + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Copilot send rejected"); + } + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 00000000000..e25062df087 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,2929 @@ +import { randomUUID } from "node:crypto"; + +import type { + CopilotClient, + CopilotSession, + ContextTier, + MessageOptions, + PermissionRequest, + PermissionRequestResult, + SessionConfig, + SessionEvent, +} from "@github/copilot-sdk"; +import { + EventId, + type CopilotSettings, + type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + type ThreadTokenUsageSnapshot, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { classifyProviderToolItemType } from "@t3tools/shared/providerToolClassification"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { createCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_RESUME_SCHEMA_VERSION = 1 as const; + +type CopilotMode = "interactive" | "plan" | "autopilot"; +type CopilotReasoningEffort = NonNullable; +type CopilotContextTier = ContextTier; +type CopilotUserInputRequest = Parameters>[0]; +type CopilotUserInputResponse = Awaited< + ReturnType> +>; +type SessionPermissionRequestedEvent = Extract; +type SessionUserInputRequestedEvent = Extract; +type SessionUserInputCompletedEvent = Extract; +type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; +type SessionApprovalDecision = Extract; +type SessionApproval = NonNullable; +type CopilotTaskStatus = "running" | "idle" | "completed" | "failed" | "cancelled"; +type CopilotTaskInfo = { + readonly description: string; + readonly status: CopilotTaskStatus; +}; +type CopilotTaskList = { + readonly tasks: ReadonlyArray; +}; +type CopilotBackgroundTasksRpc = { + readonly backgroundTasks?: { + readonly list?: () => Promise; + }; +}; + +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +export interface CopilotAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly baseDirectory?: string; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface CopilotTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface PendingPermissionHandler { + readonly signature: string; + readonly deferred: Deferred.Deferred; +} + +interface PendingUserInputHandler { + readonly signature: string; + readonly deferred: Deferred.Deferred; +} + +interface PendingPermissionBinding { + readonly requestId: string; + readonly requestType: + | "command_execution_approval" + | "file_read_approval" + | "file_change_approval"; + readonly turnId?: TurnId | undefined; + readonly permissionRequest: SessionPermissionRequest; + readonly promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined; + readonly deferred: Deferred.Deferred; +} + +interface PendingUserInputBinding { + readonly requestId: string; + readonly question: string; + readonly choices: ReadonlyArray; + readonly allowFreeform: boolean; + readonly deferred: Deferred.Deferred; +} + +interface ToolMeta { + readonly toolName: string; + readonly itemType: + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + readonly command?: string; +} + +interface CopilotToolExecutionItem { + readonly type: "tool_execution"; + readonly toolCallId: string; + readonly toolName?: string; + readonly itemType?: ToolMeta["itemType"]; + readonly success: boolean; + readonly detail?: string; +} + +interface CopilotSessionContext { + readonly threadId: ThreadId; + readonly client: CopilotClient; + readonly sdkSession: CopilotSession; + session: ProviderSession; + readonly cwd: string; + readonly turns: Array; + readonly queuedTurnIds: Array; + readonly sdkTurnIdsToTurnIds: Map; + readonly completedTurnIds: Set; + readonly turnUsageByTurnId: Map; + readonly pendingPermissionHandlersBySignature: Map>; + readonly pendingPermissionEventsBySignature: Map< + string, + Array + >; + readonly pendingPermissionBindings: Map; + readonly pendingUserInputHandlersBySignature: Map>; + readonly pendingUserInputEventsBySignature: Map< + string, + Array + >; + readonly pendingUserInputBindings: Map; + readonly toolMetaById: Map; + readonly turnIdByProviderItemId: Map; + readonly emittedTextByItemId: Map; + readonly assistantItemIdByTurnId: Map; + readonly pendingTaskCompletionTextByTurnId: Map; + readonly turnIdsWithAssistantText: Set; + readonly startedItemIds: Set; + activeTurnId: TurnId | undefined; + activeSdkTurnId: string | undefined; + eventChain: Promise; + stopped: boolean; +} + +const APPROVED_PERMISSION_RESULT = { kind: "approve-once" } satisfies PermissionRequestResult; +const DENIED_PERMISSION_RESULT = { + kind: "reject", +} satisfies PermissionRequestResult; +const EMPTY_USER_INPUT_RESPONSE = { + answer: "", + wasFreeform: true, +} satisfies CopilotUserInputResponse; + +function nowIso(): string { + return DateTime.formatIso(DateTime.nowUnsafe()); +} + +function parseCopilotResumeCursor(raw: unknown): { sessionId: string } | undefined { + if ( + !Predicate.hasProperty(raw, "schemaVersion") || + raw.schemaVersion !== COPILOT_RESUME_SCHEMA_VERSION + ) { + return undefined; + } + if (!Predicate.hasProperty(raw, "sessionId") || !Predicate.isString(raw.sessionId)) { + return undefined; + } + const sessionId = raw.sessionId.trim(); + return sessionId.length > 0 ? { sessionId } : undefined; +} + +function toCopilotResumeCursor(sessionId: string): { schemaVersion: 1; sessionId: string } { + return { + schemaVersion: COPILOT_RESUME_SCHEMA_VERSION, + sessionId, + }; +} + +function readTrimmedStringProperty( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + return typeof value === "string" ? trimOrUndefined(value) : undefined; +} + +function providerRefsFromSdkEvent( + raw: SessionEvent | undefined, + requestId: string | undefined, +): ProviderRuntimeEvent["providerRefs"] | undefined { + const refs: { + providerTurnId?: string; + providerItemId?: ProviderItemId; + providerRequestId?: string; + } = {}; + + if (requestId) { + refs.providerRequestId = requestId; + } + + const data = raw ? stringRecord(raw.data) : undefined; + if (data) { + const providerTurnId = readTrimmedStringProperty(data, "turnId"); + if (providerTurnId) { + refs.providerTurnId = providerTurnId; + } + + const providerRequestId = readTrimmedStringProperty(data, "requestId"); + if (providerRequestId) { + refs.providerRequestId = providerRequestId; + } + + const providerItemId = + readTrimmedStringProperty(data, "messageId") ?? + readTrimmedStringProperty(data, "reasoningId") ?? + readTrimmedStringProperty(data, "toolCallId") ?? + readTrimmedStringProperty(stringRecord(data.permissionRequest) ?? {}, "toolCallId"); + if (providerItemId) { + refs.providerItemId = ProviderItemId.make(providerItemId); + } + } + + return Object.keys(refs).length > 0 ? refs : undefined; +} + +function createBaseEvent(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: SessionEvent | undefined; +}) { + const providerRefs = providerRefsFromSdkEvent(input.raw, input.requestId); + return { + eventId: EventId.make(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(providerRefs ? { providerRefs } : {}), + ...(input.raw + ? { + raw: { + source: "copilot.sdk.event" as const, + method: input.raw.type, + payload: input.raw, + }, + } + : {}), + }; +} + +function ensureTurnSnapshot(context: CopilotSessionContext, turnId: TurnId): CopilotTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + const created: CopilotTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: CopilotSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + ensureTurnSnapshot(context, turnId).items.push(item); +} + +function assistantItemIdsForContext(context: CopilotSessionContext): Map { + const mutable = context as CopilotSessionContext & { + assistantItemIdByTurnId?: Map; + }; + mutable.assistantItemIdByTurnId ??= new Map(); + return mutable.assistantItemIdByTurnId; +} + +function processError( + threadId: ThreadId, + detail: string, + cause?: unknown, +): ProviderAdapterProcessError { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function validationError(operation: string, issue: string): ProviderAdapterValidationError { + return new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue, + }); +} + +function sessionClosedError(threadId: ThreadId): ProviderAdapterSessionClosedError { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }); +} + +function sessionNotFoundError(threadId: ThreadId): ProviderAdapterSessionNotFoundError { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); +} + +function detailFromCause(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; +} + +function isCopilotSessionNotFoundError(error: ProviderAdapterProcessError, sessionId: string) { + const detail = error.detail.toLowerCase(); + return detail.includes("session not found") && detail.includes(sessionId.toLowerCase()); +} + +function requireSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): Effect.Effect< + CopilotSessionContext, + ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError +> { + return Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return yield* sessionNotFoundError(threadId); + } + if (context.stopped) { + return yield* sessionClosedError(threadId); + } + return context; + }); +} + +function requestedCopilotMode(input: { + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly interactionMode?: ProviderSendTurnInput["interactionMode"] | undefined; +}): CopilotMode { + if (input.interactionMode === "plan") { + return "plan"; + } + return input.runtimeMode === "full-access" ? "autopilot" : "interactive"; +} + +function permissionAutoApprovedByRuntimeMode( + runtimeMode: ProviderSession["runtimeMode"], + request: PermissionRequest, +): boolean { + switch (runtimeMode) { + case "full-access": + return true; + case "auto-accept-edits": + return request.kind === "write"; + case "approval-required": + return false; + } +} + +function mapPermissionRequestType( + request: SessionPermissionRequest, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" { + switch (request.kind) { + case "read": + return "file_read_approval"; + case "write": + return "file_change_approval"; + default: + return "command_execution_approval"; + } +} + +function toolCallIdFromPermissionRequest(request: SessionPermissionRequest): string | undefined { + if (!Predicate.hasProperty(request, "toolCallId") || !Predicate.isString(request.toolCallId)) { + return undefined; + } + return trimOrUndefined(request.toolCallId); +} + +function permissionDetail(request: SessionPermissionRequest): string | undefined { + switch (request.kind) { + case "shell": + return trimOrUndefined(request.fullCommandText) ?? trimOrUndefined(request.intention); + case "write": + return trimOrUndefined(request.fileName) ?? trimOrUndefined(request.intention); + case "read": + return trimOrUndefined(request.path) ?? trimOrUndefined(request.intention); + case "mcp": + return trimOrUndefined(request.toolTitle) ?? `${request.serverName}:${request.toolName}`; + case "url": + return trimOrUndefined(request.url) ?? trimOrUndefined(request.intention); + case "memory": + return trimOrUndefined(request.subject); + case "custom-tool": + return trimOrUndefined(request.toolName) ?? trimOrUndefined(request.toolDescription); + case "hook": + return trimOrUndefined(request.hookMessage) ?? trimOrUndefined(request.toolName); + case "extension-management": + return [request.operation, request.extensionName] + .map((part) => trimOrUndefined(part)) + .filter(Boolean) + .join(" "); + case "extension-permission-access": + return [request.extensionName, ...request.capabilities] + .map((part) => trimOrUndefined(part)) + .filter(Boolean) + .join(" "); + default: + return undefined; + } +} + +function sessionApprovalDecisionFromPermissionRequest( + request: SessionPermissionRequest, + promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined, +): SessionApprovalDecision | undefined { + const approve = (approval: SessionApproval): SessionApprovalDecision => ({ + kind: "approve-for-session", + approval, + }); + + switch (request.kind) { + case "shell": { + if (!request.canOfferSessionApproval) { + return undefined; + } + const commandIdentifiers = + promptRequest?.kind === "commands" + ? promptRequest.commandIdentifiers + : request.commands.map((command) => command.identifier); + const identifiers = commandIdentifiers.map((identifier) => identifier.trim()).filter(Boolean); + return identifiers.length > 0 + ? approve({ kind: "commands", commandIdentifiers: identifiers }) + : undefined; + } + case "write": + return request.canOfferSessionApproval ? approve({ kind: "write" }) : undefined; + case "read": + return approve({ kind: "read" }); + case "mcp": + return approve({ kind: "mcp", serverName: request.serverName, toolName: request.toolName }); + case "url": { + try { + const domain = new URL(request.url).hostname.trim(); + return domain ? { kind: "approve-for-session", domain } : undefined; + } catch { + return undefined; + } + } + case "memory": + return approve({ kind: "memory" }); + case "custom-tool": + return approve({ kind: "custom-tool", toolName: request.toolName }); + case "extension-management": { + const operation = trimOrUndefined(request.operation); + return approve({ + kind: "extension-management", + ...(operation ? { operation } : {}), + }); + } + case "extension-permission-access": + return approve({ + kind: "extension-permission-access", + extensionName: request.extensionName, + }); + case "hook": + return undefined; + } +} + +function permissionSignature(request: SessionPermissionRequest): string { + switch (request.kind) { + case "shell": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.fullCommandText ?? null, + request.intention ?? null, + ]); + case "write": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.fileName ?? null, + request.diff ?? null, + ]); + case "read": + return JSON.stringify([request.kind, request.toolCallId ?? null, request.path ?? null]); + case "mcp": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.serverName ?? null, + request.toolName ?? null, + request.args ?? null, + ]); + case "url": + return JSON.stringify([request.kind, request.toolCallId ?? null, request.url ?? null]); + case "memory": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.subject ?? null, + request.fact ?? null, + ]); + case "custom-tool": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.toolName ?? null, + request.args ?? null, + ]); + case "hook": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.toolName ?? null, + request.toolArgs ?? null, + request.hookMessage ?? null, + ]); + case "extension-management": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.operation ?? null, + request.extensionName ?? null, + ]); + case "extension-permission-access": + return JSON.stringify([ + request.kind, + request.toolCallId ?? null, + request.extensionName ?? null, + request.capabilities ?? null, + ]); + default: + return JSON.stringify(request); + } +} + +function userInputSignature(input: { + readonly question: string; + readonly choices?: ReadonlyArray; + readonly allowFreeform?: boolean; +}): string { + return JSON.stringify([input.question, input.choices ?? [], input.allowFreeform ?? true]); +} + +function updateProviderSession( + context: CopilotSessionContext, + patch: Partial, +): void { + context.session = { + ...context.session, + ...patch, + updatedAt: nowIso(), + }; +} + +function readyStatusAfterTurnCompletion( + context: CopilotSessionContext, +): Extract { + return context.queuedTurnIds.length > 0 ? "running" : "ready"; +} + +function commonPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +function deltaFromBufferedText(previous: string | undefined, next: string): string { + return next.slice(commonPrefixLength(previous ?? "", next)); +} + +function toolItemType( + toolName: string, + mcpServerName?: string, + arguments_?: unknown, +): ToolMeta["itemType"] { + return classifyProviderToolItemType({ + toolName, + ...(mcpServerName ? { mcpServerName } : {}), + ...(arguments_ !== undefined ? { arguments: arguments_ } : {}), + }); +} + +function isTaskCompleteTool(toolName: string | undefined): boolean { + return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "taskcomplete"; +} + +function isApplyPatchTool(toolName: string | undefined): boolean { + return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "applypatch"; +} + +function hasApplyPatchEdit(detail: string): boolean { + const normalized = detail.replace(/\r\n/g, "\n").trim(); + if (!normalized.startsWith("*** Begin Patch")) { + return false; + } + return ( + normalized.includes("\n*** Update File: ") || + normalized.includes("\n*** Add File: ") || + normalized.includes("\n*** Delete File: ") || + normalized.includes("\n*** Move to: ") + ); +} + +const SHELL_COMPLETION_CONTROL_LINE_PATTERN = /^]+ completed with exit code \d+>$/; + +function stripShellCompletionControlLines(detail: string): string { + return detail + .replace(/\r\n/g, "\n") + .split("\n") + .filter((line) => !SHELL_COMPLETION_CONTROL_LINE_PATTERN.test(line.trim())) + .join("\n") + .trim(); +} + +function hasUnifiedDiffShape(detail: string): boolean { + return ( + detail.includes("diff --git ") || + /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+\n@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(detail) + ); +} + +function completedToolDiffText( + toolMeta: ToolMeta | undefined, + detail: string | undefined, +): string | undefined { + const normalized = trimOrUndefined(detail); + if (!normalized) { + return undefined; + } + if (isApplyPatchTool(toolMeta?.toolName)) { + return hasApplyPatchEdit(normalized) ? normalized : undefined; + } + if (toolMeta?.itemType !== "command_execution") { + return undefined; + } + const diffCandidate = stripShellCompletionControlLines(normalized); + if (!hasUnifiedDiffShape(diffCandidate)) { + return undefined; + } + return parseTurnDiffFilesFromUnifiedDiff(diffCandidate).length > 0 ? diffCandidate : undefined; +} + +function fileChangeTurnIdForToolCall(parentTurnId: TurnId, toolCallId: string): TurnId { + const normalizedToolCallId = toolCallId.replace(/[^A-Za-z0-9._:-]+/g, "_") || "tool"; + return TurnId.make(`${String(parentTurnId)}:file-change:${normalizedToolCallId}`); +} + +function copilotBackgroundTasksList( + session: CopilotSession, +): (() => Promise) | undefined { + const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks + ?.list; + return list; +} + +function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status"] { + switch (status) { + case "completed": + return "completed"; + case "running": + case "idle": + return "inProgress"; + case "failed": + case "cancelled": + return "pending"; + } +} + +function copilotTaskStatusSuffix(status: CopilotTaskStatus): string { + switch (status) { + case "failed": + return " (failed)"; + case "cancelled": + return " (cancelled)"; + case "running": + case "idle": + case "completed": + return ""; + } +} + +function planStepsFromCopilotTasks(tasks: ReadonlyArray): PlanStep[] { + return tasks.map((task) => { + const description = trimOrUndefined(task.description) ?? "Task"; + return { + step: `${description}${copilotTaskStatusSuffix(task.status)}`, + status: normalizeCopilotTaskStatus(task.status), + }; + }); +} + +function toolStreamKind( + itemType: ToolMeta["itemType"] | undefined, +): "command_output" | "file_change_output" | "unknown" { + if (itemType === "command_execution") { + return "command_output"; + } + if (itemType === "file_change") { + return "file_change_output"; + } + return "unknown"; +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringRecord(value: unknown): Record | undefined { + return isStringRecord(value) ? value : undefined; +} + +function commandFromToolArguments(arguments_: unknown): string | undefined { + const args = stringRecord(arguments_); + if (!args) { + return undefined; + } + + const candidates = [ + args.command, + args.cmd, + args.fullCommandText, + args.commandText, + stringRecord(args.input)?.command, + ]; + for (const candidate of candidates) { + const command = trimOrUndefined(typeof candidate === "string" ? candidate : undefined); + if (command) { + return command; + } + } + return undefined; +} + +function toolLifecycleTitle(toolMeta: ToolMeta | undefined): string { + return toolMeta?.itemType === "command_execution" + ? "Ran command" + : (toolMeta?.toolName ?? "tool"); +} + +function toolLifecycleData(input: { + readonly toolCallId: string; + readonly toolMeta: ToolMeta | undefined; + readonly arguments?: Record | undefined; + readonly result?: unknown; + readonly error?: unknown; + readonly toolTelemetry?: unknown; +}): Record { + return { + ...input.arguments, + toolCallId: input.toolCallId, + ...(input.toolMeta?.toolName ? { toolName: input.toolMeta.toolName } : {}), + ...(input.toolMeta?.command ? { command: input.toolMeta.command } : {}), + ...(input.result !== undefined ? { result: input.result } : {}), + ...(input.error ? { error: input.error } : {}), + ...(input.toolTelemetry ? { toolTelemetry: input.toolTelemetry } : {}), + }; +} + +function usageSnapshotFromAssistantUsage( + event: Extract, +): ThreadTokenUsageSnapshot { + const inputTokens = event.data.inputTokens ?? 0; + const cachedInputTokens = event.data.cacheReadTokens ?? 0; + const outputTokens = event.data.outputTokens ?? 0; + const usedTokens = inputTokens + cachedInputTokens + outputTokens; + return { + usedTokens, + lastUsedTokens: usedTokens, + ...(inputTokens > 0 ? { inputTokens, lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens > 0 + ? { cachedInputTokens, lastCachedInputTokens: cachedInputTokens } + : {}), + ...(outputTokens > 0 ? { outputTokens, lastOutputTokens: outputTokens } : {}), + ...(typeof event.data.duration === "number" && Number.isFinite(event.data.duration) + ? { durationMs: Math.max(0, Math.round(event.data.duration)) } + : {}), + }; +} + +function usageSnapshotFromUsageInfo( + event: Extract, +): ThreadTokenUsageSnapshot { + const currentTokens = Math.max(0, Math.round(event.data.currentTokens)); + return { + usedTokens: currentTokens, + lastUsedTokens: currentTokens, + ...(event.data.tokenLimit > 0 ? { maxTokens: Math.round(event.data.tokenLimit) } : {}), + ...(event.data.conversationTokens !== undefined + ? { + inputTokens: event.data.conversationTokens, + lastInputTokens: event.data.conversationTokens, + } + : {}), + }; +} + +function firstAnswerValue(answers: ProviderUserInputAnswers): string | undefined { + for (const value of Object.values(answers)) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + const first = value.find((entry) => typeof entry === "string" && entry.trim().length > 0); + if (typeof first === "string") { + return first.trim(); + } + } + } + return undefined; +} + +function answerFromUserInput( + binding: PendingUserInputBinding, + answers: ProviderUserInputAnswers, +): CopilotUserInputResponse { + const preferredAnswer = + firstAnswerValue(answers) ?? + (binding.choices.length > 0 ? binding.choices[0] : undefined) ?? + ""; + const normalizedChoices = new Set(binding.choices.map((choice) => choice.trim())); + const wasFreeform = + normalizedChoices.size === 0 ? true : !normalizedChoices.has(preferredAnswer.trim()); + return { + answer: preferredAnswer, + wasFreeform, + }; +} + +function answersFromCompletedUserInput( + data: SessionUserInputCompletedEvent["data"], +): ProviderUserInputAnswers { + return { + answer: data.answer ?? "", + }; +} + +function settlePendingPermissionHandlers( + context: CopilotSessionContext, +): Effect.Effect { + return Effect.gen(function* () { + for (const handlers of context.pendingPermissionHandlersBySignature.values()) { + for (const handler of handlers) { + yield* Deferred.succeed(handler.deferred, DENIED_PERMISSION_RESULT).pipe(Effect.ignore); + } + } + context.pendingPermissionHandlersBySignature.clear(); + context.pendingPermissionEventsBySignature.clear(); + + for (const binding of context.pendingPermissionBindings.values()) { + yield* Deferred.succeed(binding.deferred, DENIED_PERMISSION_RESULT).pipe(Effect.ignore); + } + context.pendingPermissionBindings.clear(); + }); +} + +function settlePendingUserInputs(context: CopilotSessionContext): Effect.Effect { + return Effect.gen(function* () { + for (const handlers of context.pendingUserInputHandlersBySignature.values()) { + for (const handler of handlers) { + yield* Deferred.succeed(handler.deferred, EMPTY_USER_INPUT_RESPONSE).pipe(Effect.ignore); + } + } + context.pendingUserInputHandlersBySignature.clear(); + context.pendingUserInputEventsBySignature.clear(); + + for (const binding of context.pendingUserInputBindings.values()) { + yield* Deferred.succeed(binding.deferred, EMPTY_USER_INPUT_RESPONSE).pipe(Effect.ignore); + } + context.pendingUserInputBindings.clear(); + }); +} + +function latestTurnId(context: CopilotSessionContext): TurnId | undefined { + return context.turns.at(-1)?.id; +} + +function resolveTurnIdForSdkTurn(context: CopilotSessionContext, sdkTurnId: string): TurnId { + const existing = context.sdkTurnIdsToTurnIds.get(sdkTurnId); + if (existing) { + return existing; + } + const nextTurnId = + context.queuedTurnIds.shift() ?? + context.activeTurnId ?? + latestTurnId(context) ?? + TurnId.make(`copilot-turn-${randomUUID()}`); + context.sdkTurnIdsToTurnIds.set(sdkTurnId, nextTurnId); + ensureTurnSnapshot(context, nextTurnId); + context.activeSdkTurnId = sdkTurnId; + context.activeTurnId = nextTurnId; + updateProviderSession(context, { + status: "running", + activeTurnId: nextTurnId, + }); + return nextTurnId; +} + +function resolveTurnIdForEvent( + context: CopilotSessionContext, + input?: { + readonly providerItemId?: string | undefined; + readonly sdkTurnId?: string | undefined; + readonly parentProviderItemId?: string | undefined; + }, +): TurnId | undefined { + const parentTurnId = + input?.parentProviderItemId && context.turnIdByProviderItemId.get(input.parentProviderItemId); + if (parentTurnId) { + return parentTurnId; + } + const providerItemTurnId = + input?.providerItemId && context.turnIdByProviderItemId.get(input.providerItemId); + if (providerItemTurnId) { + return providerItemTurnId; + } + if (input?.sdkTurnId) { + return resolveTurnIdForSdkTurn(context, input.sdkTurnId); + } + if (context.activeSdkTurnId) { + return context.sdkTurnIdsToTurnIds.get(context.activeSdkTurnId) ?? context.activeTurnId; + } + return context.activeTurnId ?? latestTurnId(context); +} + +export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( + settings: CopilotSettings, + options?: CopilotAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); + const serverConfig = yield* ServerConfig; + const runtimeEventPubSub = yield* PubSub.unbounded(); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const sessions = new Map(); + const path = yield* Path.Path; + const runtimeContext = yield* Effect.context(); + const runWithContext = Effect.runPromiseWith(runtimeContext); + + const emit = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); + const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => + nativeEventLogger + ? runWithContext( + nativeEventLogger.write({ source: "copilot.sdk.event", payload: event }, threadId), + ) + : Promise.resolve(); + + const copilotSdk = { + startClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + processError(threadId, detailFromCause(cause, "Failed to start Copilot client."), cause), + }), + stopClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.stop(), + catch: (cause) => + processError(threadId, detailFromCause(cause, "Failed to stop Copilot client."), cause), + }), + createSession: ( + threadId: ThreadId, + client: CopilotClient, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.createSession(config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to create Copilot session."), + cause, + ), + }), + resumeSession: ( + threadId: ThreadId, + client: CopilotClient, + sessionId: string, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.resumeSession(sessionId, config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to resume Copilot session."), + cause, + ), + }), + setMode: ( + context: CopilotSessionContext, + mode: CopilotMode, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.rpc.mode.set({ mode }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.mode.set", + detail: detailFromCause(cause, "Failed to update Copilot mode."), + cause, + }), + }), + readPlan: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => (await context.sdkSession.rpc.plan.read()).content ?? "", + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.plan.read", + detail: detailFromCause(cause, "Failed to read Copilot plan."), + cause, + }), + }), + readBackgroundTasks: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: () => copilotBackgroundTasksList(context.sdkSession)?.() ?? Promise.resolve(undefined), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.backgroundTasks.list", + detail: detailFromCause(cause, "Failed to read Copilot background tasks."), + cause, + }), + }), + setModel: ( + context: CopilotSessionContext, + model: string, + reasoningEffort?: CopilotReasoningEffort | undefined, + contextTier?: CopilotContextTier | undefined, + ): Effect.Effect => + Effect.tryPromise({ + try: () => + context.sdkSession.setModel(model, { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.setModel", + detail: detailFromCause(cause, "Failed to update Copilot model."), + cause, + }), + }), + send: ( + context: CopilotSessionContext, + messageOptions: MessageOptions, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.send(messageOptions), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: detailFromCause(cause, "Failed to send Copilot turn."), + cause, + }), + }), + abort: (context: CopilotSessionContext): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: detailFromCause(cause, "Failed to abort Copilot turn."), + cause, + }), + }), + disconnect: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.disconnect(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.disconnect", + detail: detailFromCause(cause, "Failed to disconnect Copilot session."), + cause, + }), + }), + } as const; + + const enqueueSdkEvent = (context: CopilotSessionContext, event: SessionEvent) => { + context.eventChain = context.eventChain + .then(async () => { + await writeNativeAsync(context.threadId, event); + await handleSdkEvent(context, event); + }) + .catch(async (error) => { + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message.trim() + : "Copilot event handling failed."; + updateProviderSession(context, { + status: "error", + lastError: message, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: { + error, + sourceEventType: event.type, + }, + }, + }); + }); + }; + + const emitTurnCompleted = async ( + context: CopilotSessionContext, + turnId: TurnId, + status: ProviderRuntimeTurnStatus, + input?: { + readonly stopReason?: string | null | undefined; + readonly errorMessage?: string | undefined; + readonly raw?: SessionEvent | undefined; + }, + ) => { + // Copilot can report both assistant.turn_end and session.idle for the same + // turn; keep the public runtime lifecycle canonical and idempotent. + if (context.completedTurnIds.has(turnId)) { + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + return; + } + context.completedTurnIds.add(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } + updateProviderSession(context, { + status: + status === "failed" + ? "error" + : context.stopped + ? "closed" + : readyStatusAfterTurnCompletion(context), + ...(status === "failed" && input?.errorMessage ? { lastError: input.errorMessage } : {}), + activeTurnId: undefined, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: input?.raw, + }), + type: "turn.completed", + payload: { + state: status, + ...(input?.stopReason !== undefined ? { stopReason: input.stopReason } : {}), + ...(context.turnUsageByTurnId.has(turnId) + ? { usage: context.turnUsageByTurnId.get(turnId) } + : {}), + ...(input?.errorMessage ? { errorMessage: input.errorMessage } : {}), + }, + }); + }; + + const emitTextDelta = async (input: { + readonly context: CopilotSessionContext; + readonly turnId: TurnId; + readonly itemId: string; + readonly itemType: "assistant_message" | "reasoning"; + readonly streamKind: "assistant_text" | "reasoning_text"; + readonly nextText: string; + readonly raw?: SessionEvent | undefined; + }) => { + if (!input.context.startedItemIds.has(input.itemId)) { + input.context.startedItemIds.add(input.itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + turnId: input.turnId, + itemId: input.itemId, + raw: input.raw, + }), + type: "item.started", + payload: { + itemType: input.itemType, + status: "inProgress", + }, + }); + } + + const previousText = input.context.emittedTextByItemId.get(input.itemId); + const delta = deltaFromBufferedText(previousText, input.nextText); + input.context.emittedTextByItemId.set(input.itemId, input.nextText); + if (delta.length === 0) { + return; + } + if (input.itemType === "assistant_message" && input.streamKind === "assistant_text") { + input.context.turnIdsWithAssistantText.add(input.turnId); + assistantItemIdsForContext(input.context).set(input.turnId, input.itemId); + } + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + turnId: input.turnId, + itemId: input.itemId, + raw: input.raw, + }), + type: "content.delta", + payload: { + streamKind: input.streamKind, + delta, + }, + }); + }; + + const emitPendingTaskCompletionAsAssistantMessage = async ( + context: CopilotSessionContext, + turnId: TurnId, + raw: SessionEvent, + ) => { + const content = context.pendingTaskCompletionTextByTurnId.get(turnId); + if (!content || context.turnIdsWithAssistantText.has(turnId)) { + return; + } + + context.pendingTaskCompletionTextByTurnId.delete(turnId); + const itemId = `copilot-task-completion-${String(turnId)}`; + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: content, + raw, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + detail: content, + }, + }); + appendTurnItem(context, turnId, { + type: "assistant_message", + messageId: itemId, + content, + }); + assistantItemIdsForContext(context).set(turnId, itemId); + context.turnIdsWithAssistantText.add(turnId); + }; + + const emitPermissionRequestOpened = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + data: SessionPermissionRequestedEvent["data"], + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: pending.turnId, + requestId: pending.requestId, + raw: { + ...({ + id: pending.requestId, + timestamp: nowIso(), + parentId: null, + ephemeral: true, + type: "permission.requested", + data, + } satisfies SessionPermissionRequestedEvent), + }, + }), + type: "request.opened", + payload: { + requestType: pending.requestType, + ...(permissionDetail(data.permissionRequest) + ? { detail: permissionDetail(data.permissionRequest) } + : {}), + args: data.permissionRequest, + }, + }); + + const emitPermissionRequestResolved = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + decision: ProviderApprovalDecision | PermissionRequestResult["kind"], + resolution: unknown, + raw?: SessionEvent, + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: pending.turnId, + requestId: pending.requestId, + raw, + }), + type: "request.resolved", + payload: { + requestType: pending.requestType, + decision, + resolution, + }, + }); + + const emitUserInputRequested = ( + context: CopilotSessionContext, + requestId: string, + request: PendingUserInputBinding, + raw?: SessionEvent, + ): Effect.Effect => { + const options = request.choices.map((choice) => ({ + label: choice, + description: choice, + })); + const questions: ReadonlyArray = [ + { + id: "answer", + header: "Input", + question: request.question.trim(), + options, + ...(options.length > 1 ? { multiSelect: false } : {}), + }, + ]; + return emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId, + raw, + }), + type: "user-input.requested", + payload: { + questions, + }, + }); + }; + + const bindPermissionRequests = ( + context: CopilotSessionContext, + signature: string, + ): Effect.Effect => + Effect.gen(function* () { + const pendingHandlers = context.pendingPermissionHandlersBySignature.get(signature); + const pendingEvents = context.pendingPermissionEventsBySignature.get(signature); + if (!pendingHandlers?.length || !pendingEvents?.length) { + return; + } + + while (pendingHandlers.length > 0 && pendingEvents.length > 0) { + const handler = pendingHandlers.shift()!; + const eventData = pendingEvents.shift()!; + const requestId = eventData.requestId.trim(); + const turnId = resolveTurnIdForEvent(context, { + providerItemId: toolCallIdFromPermissionRequest(eventData.permissionRequest), + sdkTurnId: context.activeSdkTurnId, + }); + context.pendingPermissionBindings.set(requestId, { + requestId, + requestType: mapPermissionRequestType(eventData.permissionRequest), + ...(turnId ? { turnId } : {}), + permissionRequest: eventData.permissionRequest, + promptRequest: eventData.promptRequest, + deferred: handler.deferred, + }); + if (eventData.resolvedByHook !== true) { + yield* emitPermissionRequestOpened( + context, + context.pendingPermissionBindings.get(requestId)!, + eventData, + ); + } + } + + if (pendingHandlers.length === 0) { + context.pendingPermissionHandlersBySignature.delete(signature); + } + if (pendingEvents.length === 0) { + context.pendingPermissionEventsBySignature.delete(signature); + } + }); + + const bindUserInputRequests = ( + context: CopilotSessionContext, + signature: string, + ): Effect.Effect => + Effect.gen(function* () { + const pendingHandlers = context.pendingUserInputHandlersBySignature.get(signature); + const pendingEvents = context.pendingUserInputEventsBySignature.get(signature); + if (!pendingHandlers?.length || !pendingEvents?.length) { + return; + } + + while (pendingHandlers.length > 0 && pendingEvents.length > 0) { + const handler = pendingHandlers.shift()!; + const eventData = pendingEvents.shift()!; + const requestId = eventData.requestId.trim(); + const binding: PendingUserInputBinding = { + requestId, + question: eventData.question.trim(), + choices: eventData.choices?.map((choice) => choice.trim()).filter(Boolean) ?? [], + allowFreeform: eventData.allowFreeform ?? true, + deferred: handler.deferred, + }; + context.pendingUserInputBindings.set(requestId, binding); + yield* emitUserInputRequested(context, requestId, binding, { + id: requestId, + timestamp: nowIso(), + parentId: null, + type: "user_input.requested", + ephemeral: true, + data: eventData, + }); + } + + if (pendingHandlers.length === 0) { + context.pendingUserInputHandlersBySignature.delete(signature); + } + if (pendingEvents.length === 0) { + context.pendingUserInputEventsBySignature.delete(signature); + } + }); + + const emitBackgroundTasksPlanSnapshot = ( + context: CopilotSessionContext, + raw: SessionEvent, + ): Effect.Effect => + Effect.gen(function* () { + const turnId = context.activeTurnId ?? latestTurnId(context); + if (!turnId) { + return; + } + const taskList = yield* copilotSdk.readBackgroundTasks(context); + if (!taskList) { + return; + } + const plan = planStepsFromCopilotTasks(taskList.tasks); + if (plan.length === 0) { + return; + } + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.plan.updated", + payload: { + explanation: "Copilot Tasks", + plan, + }, + }); + }); + + const onPermissionRequest = ( + context: CopilotSessionContext, + request: PermissionRequest, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) { + return DENIED_PERMISSION_RESULT; + } + if (permissionAutoApprovedByRuntimeMode(context.session.runtimeMode, request)) { + return APPROVED_PERMISSION_RESULT; + } + + const signature = permissionSignature(request); + const deferred = yield* Deferred.make(); + const queue = context.pendingPermissionHandlersBySignature.get(signature) ?? []; + queue.push({ + signature, + deferred, + }); + context.pendingPermissionHandlersBySignature.set(signature, queue); + yield* bindPermissionRequests(context, signature); + return yield* Deferred.await(deferred); + }); + + const onUserInputRequest = ( + context: CopilotSessionContext, + request: CopilotUserInputRequest, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) { + return EMPTY_USER_INPUT_RESPONSE; + } + + const signature = userInputSignature(request); + const deferred = yield* Deferred.make(); + const queue = context.pendingUserInputHandlersBySignature.get(signature) ?? []; + queue.push({ + signature, + deferred, + }); + context.pendingUserInputHandlersBySignature.set(signature, queue); + yield* bindUserInputRequests(context, signature); + return yield* Deferred.await(deferred); + }); + + const syncSessionMode = ( + context: CopilotSessionContext, + mode: CopilotMode, + ): Effect.Effect => + copilotSdk.setMode(context, mode).pipe( + Effect.flatMap(() => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + }), + type: "session.configured", + payload: { + config: { + mode, + }, + }, + }), + ), + Effect.catch((cause) => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + }), + type: "runtime.warning", + payload: { + message: "Failed to synchronize Copilot mode with the requested runtime mode.", + detail: cause, + }, + }), + ), + ); + + const emitPlanSnapshot = ( + context: CopilotSessionContext, + raw: SessionEvent, + fallbackPlan?: string | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const turnId = context.activeTurnId ?? latestTurnId(context); + if (!turnId) { + return; + } + const plan = fallbackPlan + ? fallbackPlan.trim() + : (yield* copilotSdk.readPlan(context)).trim(); + if (plan.length === 0) { + return; + } + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.proposed.completed", + payload: { + planMarkdown: plan, + }, + }); + }); + + const handleSdkEvent = async ( + context: CopilotSessionContext, + event: SessionEvent, + ): Promise => { + switch (event.type) { + case "session.start": { + updateProviderSession(context, { + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(event.data.sessionId), + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.started", + payload: { + message: "Copilot session started.", + resume: toCopilotResumeCursor(event.data.sessionId), + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, + }, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: "Copilot session ready", + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.started", + payload: { + providerThreadId: event.data.sessionId, + }, + }); + return; + } + case "session.resume": { + updateProviderSession(context, { + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(context.sdkSession.sessionId), + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.started", + payload: { + message: "Copilot session resumed.", + resume: toCopilotResumeCursor(context.sdkSession.sessionId), + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, + }, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: "Copilot session resumed", + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.started", + payload: { + providerThreadId: context.sdkSession.sessionId, + }, + }); + return; + } + case "session.error": { + const message = trimOrUndefined(event.data.message) ?? "Copilot session failed."; + const activeTurnId = context.activeTurnId; + updateProviderSession(context, { + status: "error", + lastError: message, + activeTurnId: undefined, + }); + if (activeTurnId) { + await emitTurnCompleted(context, activeTurnId, "failed", { + errorMessage: message, + raw: event, + }); + } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.data, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "error", + reason: message, + detail: event.data, + }, + }); + return; + } + case "session.idle": { + if (context.activeTurnId) { + const turnId = context.activeTurnId; + if (!event.data.aborted) { + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + } + await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { + raw: event, + stopReason: event.data.aborted ? "aborted" : null, + }); + } + const idleStatus = context.stopped ? "closed" : readyStatusAfterTurnCompletion(context); + const idleState = context.stopped ? "stopped" : readyStatusAfterTurnCompletion(context); + updateProviderSession(context, { + status: idleStatus, + activeTurnId: undefined, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: idleState, + reason: event.data.aborted ? "Copilot turn aborted." : "Copilot idle.", + }, + }); + return; + } + case "session.title_changed": { + const title = trimOrUndefined(event.data.title); + if (!title) { + return; + } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.metadata.updated", + payload: { + name: title, + }, + }); + return; + } + case "session.warning": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "runtime.warning", + payload: { + message: trimOrUndefined(event.data.message) ?? "", + detail: event.data, + }, + }); + return; + } + case "session.model_change": { + updateProviderSession(context, { + model: trimOrUndefined(event.data.newModel) ?? context.session.model, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.newModel, + reasoningEffort: event.data.reasoningEffort ?? null, + previousModel: event.data.previousModel ?? null, + }, + }, + }); + return; + } + case "session.mode_changed": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + mode: event.data.newMode, + previousMode: event.data.previousMode, + }, + }, + }); + return; + } + case "session.plan_changed": { + if (event.data.operation === "delete") { + return; + } + await runWithContext(emitPlanSnapshot(context, event)); + return; + } + case "session.usage_info": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: context.activeTurnId, + raw: event, + }), + type: "thread.token-usage.updated", + payload: { + usage: usageSnapshotFromUsageInfo(event), + }, + }); + return; + } + case "session.background_tasks_changed": { + await runWithContext(emitBackgroundTasksPlanSnapshot(context, event)); + return; + } + case "assistant.turn_start": { + const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); + updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "running", + reason: "Copilot turn started", + }, + }); + return; + } + case "assistant.reasoning_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.reasoning": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: event.data.content, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + }, + }); + appendTurnItem(context, turnId, { + type: "reasoning", + reasoningId: event.data.reasoningId, + content: event.data.content, + }); + return; + } + case "assistant.message_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + assistantItemIdsForContext(context).set(turnId, itemId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.message": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + assistantItemIdsForContext(context).set(turnId, itemId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: event.data.content, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + if (event.data.reasoningText?.trim()) { + const reasoningItemId = `copilot-message-reasoning-${event.data.messageId}`; + await emitTextDelta({ + context, + turnId, + itemId: reasoningItemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: event.data.reasoningText, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId: reasoningItemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + }, + }); + } + appendTurnItem(context, turnId, { + type: "assistant_message", + messageId: event.data.messageId, + content: event.data.content, + }); + return; + } + case "assistant.turn_end": { + const turnId = context.sdkTurnIdsToTurnIds.get(event.data.turnId) ?? context.activeTurnId; + if (!turnId) { + return; + } + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitTurnCompleted(context, turnId, "completed", { + raw: event, + stopReason: null, + }); + if (context.activeSdkTurnId === event.data.turnId) { + context.activeSdkTurnId = undefined; + } + return; + } + case "assistant.usage": { + const turnId = resolveTurnIdForEvent(context, { + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const usage = usageSnapshotFromAssistantUsage(event); + context.turnUsageByTurnId.set(turnId, usage); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "thread.token-usage.updated", + payload: { + usage, + }, + }); + return; + } + case "abort": { + const turnId = context.activeTurnId; + if (!turnId) { + return; + } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "turn.aborted", + payload: { + reason: event.data.reason, + }, + }); + await emitTurnCompleted(context, turnId, "cancelled", { + raw: event, + stopReason: "aborted", + }); + return; + } + case "tool.execution_start": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const itemType = toolItemType( + event.data.toolName, + event.data.mcpServerName, + event.data.arguments, + ); + const command = + itemType === "command_execution" + ? commandFromToolArguments(event.data.arguments) + : undefined; + const toolMeta: ToolMeta = { + toolName: event.data.toolName, + itemType, + ...(command ? { command } : {}), + }; + context.toolMetaById.set(event.data.toolCallId, { + ...toolMeta, + }); + context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); + context.startedItemIds.add(itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.started", + payload: { + itemType, + status: "inProgress", + title: toolLifecycleTitle(toolMeta), + data: toolLifecycleData({ + toolCallId: event.data.toolCallId, + toolMeta, + ...(event.data.arguments ? { arguments: event.data.arguments } : {}), + }), + }, + }); + return; + } + case "tool.execution_partial_result": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "content.delta", + payload: { + streamKind: toolStreamKind(toolMeta?.itemType), + delta: event.data.partialOutput, + }, + }); + return; + } + case "tool.execution_progress": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + const summary = trimOrUndefined(event.data.progressMessage); + if (!turnId || !summary) { + return; + } + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId: `copilot-tool-${event.data.toolCallId}`, + raw: event, + }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + ...(toolMeta ? { toolName: toolMeta.toolName } : {}), + summary, + }, + }); + return; + } + case "tool.execution_complete": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const eventData = stringRecord(event.data); + const eventToolName = trimOrUndefined( + typeof eventData?.toolName === "string" ? eventData.toolName : undefined, + ); + const fallbackToolMeta: ToolMeta | undefined = eventToolName + ? { + toolName: eventToolName, + itemType: toolItemType(eventToolName, undefined, eventData?.arguments), + } + : undefined; + const toolMeta = context.toolMetaById.get(event.data.toolCallId) ?? fallbackToolMeta; + const detail = + trimOrUndefined(event.data.result?.detailedContent) ?? + trimOrUndefined(event.data.result?.content) ?? + trimOrUndefined(event.data.error?.message); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: toolMeta?.itemType ?? "dynamic_tool_call", + status: event.data.success ? "completed" : "failed", + title: toolLifecycleTitle(toolMeta), + ...(detail ? { detail } : {}), + data: toolLifecycleData({ + toolCallId: event.data.toolCallId, + toolMeta, + ...(event.data.result !== undefined ? { result: event.data.result } : {}), + ...(event.data.error ? { error: event.data.error } : {}), + ...(event.data.toolTelemetry ? { toolTelemetry: event.data.toolTelemetry } : {}), + }), + }, + }); + const diffText = event.data.success ? completedToolDiffText(toolMeta, detail) : undefined; + if (diffText) { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: fileChangeTurnIdForToolCall(turnId, event.data.toolCallId), + raw: event, + }), + type: "turn.diff.updated", + payload: { + unifiedDiff: diffText, + }, + }); + } + const toolItem: CopilotToolExecutionItem = { + type: "tool_execution", + toolCallId: event.data.toolCallId, + ...(toolMeta?.toolName ? { toolName: toolMeta.toolName } : {}), + ...(toolMeta?.itemType ? { itemType: toolMeta.itemType } : {}), + success: event.data.success, + ...(detail ? { detail } : {}), + }; + appendTurnItem(context, turnId, toolItem); + if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + } + return; + } + case "permission.requested": { + if (event.data.resolvedByHook === true) { + return; + } + const signature = permissionSignature(event.data.permissionRequest); + const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; + queue.push(event.data); + context.pendingPermissionEventsBySignature.set(signature, queue); + await runWithContext(bindPermissionRequests(context, signature)); + return; + } + case "permission.completed": { + const binding = context.pendingPermissionBindings.get(event.data.requestId); + if (!binding) { + return; + } + context.pendingPermissionBindings.delete(event.data.requestId); + await runWithContext( + emitPermissionRequestResolved( + context, + binding, + event.data.result.kind, + event.data.result, + event, + ), + ); + return; + } + case "user_input.requested": { + const signature = userInputSignature({ + question: event.data.question, + ...(event.data.choices ? { choices: event.data.choices } : {}), + ...(event.data.allowFreeform !== undefined + ? { allowFreeform: event.data.allowFreeform } + : {}), + }); + const queue = context.pendingUserInputEventsBySignature.get(signature) ?? []; + queue.push(event.data); + context.pendingUserInputEventsBySignature.set(signature, queue); + await runWithContext(bindUserInputRequests(context, signature)); + return; + } + case "user_input.completed": { + const binding = context.pendingUserInputBindings.get(event.data.requestId); + if (!binding) { + return; + } + context.pendingUserInputBindings.delete(event.data.requestId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: binding.requestId, + raw: event, + }), + type: "user-input.resolved", + payload: { + answers: answersFromCompletedUserInput(event.data), + }, + }); + return; + } + case "exit_plan_mode.requested": { + await runWithContext(emitPlanSnapshot(context, event, event.data.planContent)); + return; + } + case "exit_plan_mode.completed": + default: + return; + } + }; + + const startSession: CopilotAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* validationError( + "startSession", + `Expected provider '${PROVIDER}', received '${input.provider}'.`, + ); + } + if (input.providerInstanceId !== undefined && input.providerInstanceId !== boundInstanceId) { + return yield* validationError( + "startSession", + `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, + ); + } + + if (sessions.has(input.threadId)) { + yield* stopSessionInternal(input.threadId); + } + + if (!settings.enabled) { + return yield* validationError("startSession", "Copilot is disabled in server settings."); + } + + const cwd = path.resolve(input.cwd ?? serverConfig.cwd); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const reasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ) as CopilotReasoningEffort | undefined; + const contextTier = getModelSelectionStringOptionValue(modelSelection, "contextTier") as + | CopilotContextTier + | undefined; + let context: CopilotSessionContext | undefined; + const earlyEvents: Array = []; + const onEvent: SessionConfig["onEvent"] = (event) => { + if (!context) { + earlyEvents.push(event); + return; + } + enqueueSdkEvent(context, event); + }; + const onSessionPermissionRequest = (_request: PermissionRequest) => { + return runWithContext( + context + ? onPermissionRequest(context, _request) + : Effect.succeed( + permissionAutoApprovedByRuntimeMode(input.runtimeMode, _request) + ? APPROVED_PERMISSION_RESULT + : DENIED_PERMISSION_RESULT, + ), + ); + }; + const onSessionUserInputRequest = (_request: CopilotUserInputRequest) => { + return runWithContext( + context + ? onUserInputRequest(context, _request) + : Effect.succeed(EMPTY_USER_INPUT_RESPONSE), + ); + }; + + const platform = yield* HostProcessPlatform; + const client = yield* createCopilotClient({ + settings, + cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + ...(options?.environment ? { env: options.environment } : {}), + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), + ), + ); + + const baseSessionConfig = { + clientName: "t3-code", + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), + workingDirectory: cwd, + streaming: true, + enableConfigDiscovery: true, + onEvent, + } satisfies Pick< + SessionConfig, + | "clientName" + | "model" + | "reasoningEffort" + | "contextTier" + | "workingDirectory" + | "streaming" + | "enableConfigDiscovery" + | "onEvent" + >; + + const createFreshSdkSession = () => + copilotSdk.createSession(input.threadId, client, { + ...baseSessionConfig, + sessionId: input.threadId, + onPermissionRequest: onSessionPermissionRequest, + onUserInputRequest: onSessionUserInputRequest, + }); + + const sdkSession = yield* Effect.gen(function* () { + yield* copilotSdk.startClient(input.threadId, client); + const resume = parseCopilotResumeCursor(input.resumeCursor); + if (resume) { + return yield* copilotSdk + .resumeSession(input.threadId, client, resume.sessionId, { + ...baseSessionConfig, + onPermissionRequest: onSessionPermissionRequest, + onUserInputRequest: onSessionUserInputRequest, + }) + .pipe( + Effect.catch((error) => + isCopilotSessionNotFoundError(error, resume.sessionId) + ? Effect.logInfo("copilot resume cursor is stale; starting a fresh session", { + threadId: input.threadId, + sessionId: resume.sessionId, + }).pipe(Effect.andThen(createFreshSdkSession())) + : Effect.fail(error), + ), + ); + } + return yield* createFreshSdkSession(); + }).pipe( + Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), + ); + + context = { + threadId: input.threadId, + client, + sdkSession, + cwd, + session: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + runtimeMode: input.runtimeMode, + cwd, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + threadId: input.threadId, + resumeCursor: toCopilotResumeCursor(sdkSession.sessionId), + createdAt: nowIso(), + updatedAt: nowIso(), + }, + turns: [], + queuedTurnIds: [], + sdkTurnIdsToTurnIds: new Map(), + completedTurnIds: new Set(), + turnUsageByTurnId: new Map(), + pendingPermissionHandlersBySignature: new Map(), + pendingPermissionEventsBySignature: new Map(), + pendingPermissionBindings: new Map(), + pendingUserInputHandlersBySignature: new Map(), + pendingUserInputEventsBySignature: new Map(), + pendingUserInputBindings: new Map(), + toolMetaById: new Map(), + turnIdByProviderItemId: new Map(), + emittedTextByItemId: new Map(), + assistantItemIdByTurnId: new Map(), + pendingTaskCompletionTextByTurnId: new Map(), + turnIdsWithAssistantText: new Set(), + startedItemIds: new Set(), + activeTurnId: undefined, + activeSdkTurnId: undefined, + eventChain: Promise.resolve(), + stopped: false, + }; + sessions.set(input.threadId, context); + + yield* syncSessionMode( + context, + requestedCopilotMode({ + runtimeMode: input.runtimeMode, + }), + ); + + for (const event of earlyEvents) { + enqueueSdkEvent(context, event); + } + yield* Effect.promise(() => context.eventChain).pipe( + Effect.mapError((cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to process Copilot startup events."), + cause, + ), + ), + ); + updateProviderSession(context, { + status: context.session.status === "connecting" ? "ready" : context.session.status, + }); + + return context.session; + }, + ); + + const sendTurn: CopilotAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = yield* requireSessionContext(sessions, input.threadId); + + const text = input.input?.trim(); + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const filePath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!filePath) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: filePath, + displayName: attachment.name, + }); + }); + if ((!text || text.length === 0) && attachments.length === 0) { + return yield* validationError( + "sendTurn", + "Copilot turns require text input or at least one attachment.", + ); + } + + const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const rawReasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); + const reasoningEffort = rawReasoningEffort as CopilotReasoningEffort | undefined; + const rawContextTier = getModelSelectionStringOptionValue(modelSelection, "contextTier"); + const contextTier = rawContextTier as CopilotContextTier | undefined; + if (modelSelection?.model) { + yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort, contextTier); + updateProviderSession(context, { + model: modelSelection.model, + ...(reasoningEffort || contextTier ? { status: "ready" } : {}), + }); + } + + const mode = requestedCopilotMode({ + runtimeMode: context.session.runtimeMode, + interactionMode: input.interactionMode, + }); + yield* syncSessionMode(context, mode); + + ensureTurnSnapshot(context, turnId); + context.queuedTurnIds.push(turnId); + const shouldPromoteQueuedTurn = + context.activeTurnId === undefined && context.activeSdkTurnId === undefined; + if (shouldPromoteQueuedTurn) { + context.activeTurnId = turnId; + } + updateProviderSession(context, { + status: "running", + ...(shouldPromoteQueuedTurn ? { activeTurnId: turnId } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + }); + + yield* emit({ + ...createBaseEvent({ + threadId: input.threadId, + turnId, + }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(reasoningEffort ? { effort: reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), + }, + }); + + const messageOptions: MessageOptions = { + prompt: text ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }; + + yield* copilotSdk.send(context, messageOptions).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const queueIndex = context.queuedTurnIds.indexOf(turnId); + if (queueIndex >= 0) { + context.queuedTurnIds.splice(queueIndex, 1); + } + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } + updateProviderSession(context, { + status: context.activeTurnId ? "running" : readyStatusAfterTurnCompletion(context), + ...(context.activeTurnId + ? { activeTurnId: context.activeTurnId } + : { activeTurnId: undefined }), + }); + yield* emit({ + ...createBaseEvent({ + threadId: input.threadId, + turnId, + }), + type: "turn.aborted", + payload: { + reason: error.detail, + }, + }); + yield* Effect.tryPromise({ + try: () => + emitTurnCompleted(context, turnId, "failed", { + errorMessage: error.detail, + }), + catch: (cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to emit Copilot turn completion."), + cause, + ), + }); + return yield* error; + }), + ), + ); + + return { + threadId: input.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = yield* requireSessionContext(sessions, threadId); + + yield* copilotSdk.abort(context); + + const targetTurnId = turnId ?? context.activeTurnId; + if (targetTurnId) { + yield* emit({ + ...createBaseEvent({ + threadId, + turnId: targetTurnId, + }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( + function* (threadId, requestId, decision) { + const context = yield* requireSessionContext(sessions, threadId); + + const binding = context.pendingPermissionBindings.get(requestId); + if (!binding) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const approvalDecision = sessionApprovalDecisionFromPermissionRequest( + binding.permissionRequest, + binding.promptRequest, + ); + const result: PermissionRequestResult = + decision === "accept" + ? APPROVED_PERMISSION_RESULT + : decision === "acceptForSession" && approvalDecision + ? approvalDecision + : decision === "acceptForSession" + ? APPROVED_PERMISSION_RESULT + : DENIED_PERMISSION_RESULT; + yield* emitPermissionRequestResolved(context, binding, decision, result); + context.pendingPermissionBindings.delete(requestId); + yield* Deferred.succeed(binding.deferred, result); + }, + ); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = yield* requireSessionContext(sessions, threadId); + + const binding = context.pendingUserInputBindings.get(requestId); + if (!binding) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "user_input.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + const response = answerFromUserInput(binding, answers); + yield* Deferred.succeed(binding.deferred, response); + }); + + const stopSessionInternal = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return; + } + if (context.stopped) { + sessions.delete(threadId); + return; + } + + context.stopped = true; + yield* Effect.promise(() => context.eventChain); + yield* settlePendingPermissionHandlers(context); + yield* settlePendingUserInputs(context); + yield* copilotSdk.disconnect(context).pipe(Effect.ignore); + yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + + updateProviderSession(context, { + status: "closed", + activeTurnId: undefined, + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.state.changed", + payload: { + state: "stopped", + reason: "Copilot session stopped.", + }, + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.exited", + payload: { + reason: "Copilot session stopped.", + exitKind: "graceful", + }, + }); + sessions.delete(threadId); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + yield* stopSessionInternal(threadId); + }, + ); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (context) => context.session)); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = yield* requireSessionContext(sessions, threadId); + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }, + ); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, _numTurns) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: "Copilot SDK does not expose thread rollback.", + }); + }, + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.gen(function* () { + yield* Effect.forEach(Array.from(sessions.keys()), stopSessionInternal, { + concurrency: "unbounded", + discard: true, + }); + if (managedNativeEventLogger) { + yield* managedNativeEventLogger.close(); + } + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromPubSub(runtimeEventPubSub); + }, + } satisfies CopilotAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts new file mode 100644 index 00000000000..99993740470 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; + +import { beforeEach, describe, it } from "@effect/vitest"; +import { CopilotSettings } from "@t3tools/contracts"; +import { DateTime, Effect, Schema } from "effect"; +import { vi } from "vite-plus/test"; + +import { checkCopilotProviderStatus } from "./CopilotProvider.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + listModelsError: null as Error | null, + createClientError: null as Error | null, + }; + + return { + state, + reset() { + state.listModelsError = null; + state.createClientError = null; + }, + }; +}); + +vi.mock("../copilotRuntime.ts", async () => { + const actual = + await vi.importActual("../copilotRuntime.ts"); + + return { + ...actual, + createCopilotClient: vi.fn(() => { + if (runtimeMock.state.createClientError) { + return Effect.fail(runtimeMock.state.createClientError); + } + return Effect.succeed({ + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({ + version: "1.0.32", + protocolVersion: 3, + })), + getAuthStatus: vi.fn(async () => ({ + isAuthenticated: true, + authType: "gh-cli", + host: "https://github.com", + statusMessage: "zortos293 (via gh)", + login: "zortos293", + })), + listModels: vi.fn(async () => { + if (runtimeMock.state.listModelsError) { + throw runtimeMock.state.listModelsError; + } + return []; + }), + }); + }), + }; +}); + +beforeEach(() => { + vi.useRealTimers(); + runtimeMock.reset(); +}); + +const defaultCopilotSettings: CopilotSettings = Schema.decodeSync(CopilotSettings)({}); + +describe("CopilotProvider status", () => { + it.effect("surfaces underlying SDK errors instead of leaking Effect.tryPromise text", () => + Effect.gen(function* () { + runtimeMock.state.listModelsError = new Error("401 Unauthorized"); + + const snapshot = yield* checkCopilotProviderStatus({ + settings: defaultCopilotSettings, + cwd: process.cwd(), + }); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "401 Unauthorized"); + }), + ); + + it.effect("returns an error snapshot when the configured Copilot CLI path is invalid", () => + Effect.gen(function* () { + runtimeMock.state.createClientError = new Error( + "The configured Copilot binary could not be found: /missing/copilot.", + ); + + const snapshot = yield* checkCopilotProviderStatus({ + settings: { + ...defaultCopilotSettings, + binaryPath: "/missing/copilot", + }, + cwd: process.cwd(), + }); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal( + snapshot.message, + "The configured Copilot binary could not be started: /missing/copilot.", + ); + }), + ); + + it.effect("timestamps each provider status check when the Effect executes", () => + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["Date"] }); + + const statusCheck = checkCopilotProviderStatus({ + settings: defaultCopilotSettings, + cwd: process.cwd(), + }); + + vi.setSystemTime(DateTime.makeUnsafe("2026-06-08T12:00:00.000Z").epochMilliseconds); + const firstSnapshot = yield* statusCheck; + + vi.setSystemTime(DateTime.makeUnsafe("2026-06-08T12:01:00.000Z").epochMilliseconds); + const secondSnapshot = yield* statusCheck; + + assert.equal(firstSnapshot.checkedAt, "2026-06-08T12:00:00.000Z"); + assert.equal(secondSnapshot.checkedAt, "2026-06-08T12:01:00.000Z"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts new file mode 100644 index 00000000000..050925a9eaa --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,154 @@ +import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { DateTime, Effect } from "effect"; + +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { + authSnapshotFromCopilotSdk, + createCopilotClient, + formatCopilotProbeError, + modelsFromCopilotSdk, + toCopilotProbeError, + versionFromCopilotStatus, +} from "../copilotRuntime.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_PRESENTATION = { + displayName: "GitHub Copilot", + showInteractionModeToggle: true, +} as const; + +export function makePendingCopilotProvider(settings: CopilotSettings): ServerProviderDraft { + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); + const models = modelsFromCopilotSdk({ + models: [], + customModels: settings.customModels, + }); + + if (!settings.enabled) { + return buildServerProvider({ + driver: PROVIDER, + presentation: COPILOT_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Copilot is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + driver: PROVIDER, + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking GitHub Copilot SDK availability...", + }, + }); +} + +export function checkCopilotProviderStatus(input: { + readonly settings: CopilotSettings; + readonly cwd: string; + readonly baseDirectory?: string | undefined; + readonly environment?: NodeJS.ProcessEnv | undefined; +}): Effect.Effect { + if (!input.settings.enabled) { + return Effect.succeed(makePendingCopilotProvider(input.settings)); + } + + const fallback = (cause: unknown, version: string | null = null) => { + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); + const failure = formatCopilotProbeError({ + cause, + settings: input.settings, + }); + return buildServerProvider({ + driver: PROVIDER, + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: modelsFromCopilotSdk({ + models: [], + customModels: input.settings.customModels, + }), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + return Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return yield* Effect.acquireUseRelease( + createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), + ...(input.environment ? { env: input.environment } : {}), + platform, + logLevel: "error", + }).pipe(Effect.mapError(toCopilotProbeError)), + (client) => + Effect.tryPromise({ + try: async () => { + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); + await client.start(); + const [status, authStatus, models] = await Promise.all([ + client.getStatus(), + client.getAuthStatus(), + client.listModels(), + ]); + const authSnapshot = authSnapshotFromCopilotSdk(authStatus); + const providerModels = modelsFromCopilotSdk({ + models, + customModels: input.settings.customModels, + }); + const hasBuiltInModels = models.length > 0; + + return buildServerProvider({ + driver: PROVIDER, + presentation: COPILOT_PRESENTATION, + enabled: true, + checkedAt, + models: providerModels, + probe: { + installed: true, + version: versionFromCopilotStatus(status), + status: + authSnapshot.status !== "ready" + ? authSnapshot.status + : hasBuiltInModels + ? "ready" + : "warning", + auth: authSnapshot.auth, + ...(authSnapshot.message + ? { message: authSnapshot.message } + : hasBuiltInModels + ? {} + : { message: "Copilot did not report any available models for this account." }), + }, + }); + }, + catch: toCopilotProbeError, + }).pipe(Effect.catch((cause) => Effect.succeed(fallback(cause)))), + (client) => Effect.promise(() => client.stop()).pipe(Effect.ignore({ log: true })), + ).pipe(Effect.catch((cause) => Effect.succeed(fallback(cause)))); + }); +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5fe0f903686..7dc3c6f8b67 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1395,6 +1395,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ "claudeAgent", "codex", + "copilot", "cursor", "grok", "opencode", diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 00000000000..e203a7e230f --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,18 @@ +/** + * CopilotAdapter — shape type for the GitHub Copilot provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/CopilotDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module CopilotAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * CopilotAdapterShape — per-instance GitHub Copilot adapter contract. + */ +export interface CopilotAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..a296427bcf1 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -22,6 +22,7 @@ */ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; @@ -35,6 +36,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv + | CopilotDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -46,6 +48,7 @@ export type BuiltInDriversEnv = */ export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, + CopilotDriver, ClaudeDriver, CursorDriver, GrokDriver, diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts new file mode 100644 index 00000000000..03e31366ad9 --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it } from "@effect/vitest"; +import type { CopilotClientOptions } from "@github/copilot-sdk"; +import * as Effect from "effect/Effect"; + +import { + authSnapshotFromCopilotSdk, + buildCopilotClientOptions, + capabilitiesFromCopilotModel, + normalizeCopilotRuntimeEnvironment, + resolveBundledCopilotCliPath, +} from "./copilotRuntime.ts"; + +function assertStdioConnection(connection: CopilotClientOptions["connection"]) { + assert.equal(connection?.kind, "stdio"); + return connection; +} + +const POSIX_SHELL_FALLBACKS = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; + +describe("buildCopilotClientOptions", () => { + it("leaves POSIX PATH hydration to the shared server environment setup", () => { + const env = normalizeCopilotRuntimeEnvironment({ PATH: "/custom/bin:/bin" }, "darwin"); + + assert.equal(env.PATH, "/custom/bin:/bin"); + }); + + describe("capabilitiesFromCopilotModel", () => { + it("adds a context tier selector for long-context Copilot models", () => { + const capabilities = capabilitiesFromCopilotModel({ + capabilities: { + supports: { vision: false, reasoningEffort: true }, + limits: { max_prompt_tokens: 922_000, max_context_window_tokens: 1_050_000 }, + }, + billing: { + tokenPrices: { + contextMax: 272_000, + longContext: { contextMax: 922_000 }, + }, + }, + supportedReasoningEfforts: ["none", "low", "medium", "high", "max"], + defaultReasoningEffort: "medium", + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "none", label: "None" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + currentValue: "medium", + }, + { + id: "contextTier", + label: "Context Window", + type: "select", + options: [ + { id: "default", label: "Default (272K tokens)" }, + { id: "long_context", label: "Long Context (1.05M tokens)" }, + ], + currentValue: "default", + }, + ]); + }); + + it("omits the context tier selector for regular-context Copilot models", () => { + const capabilities = capabilitiesFromCopilotModel({ + capabilities: { + supports: { vision: false, reasoningEffort: false }, + limits: { max_prompt_tokens: 272_000, max_context_window_tokens: 400_000 }, + }, + billing: { + tokenPrices: { + contextMax: 272_000, + }, + }, + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, []); + }); + }); + + it("hydrates a missing POSIX SHELL for Copilot shell spawning", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + assert.ok(POSIX_SHELL_FALLBACKS.some((shell) => shell === env.SHELL)); + }); + + it("replaces POSIX SHELL values that the Copilot CLI rejects", () => { + const fallbackShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; + const relativeShellEnv = normalizeCopilotRuntimeEnvironment({ SHELL: "bash" }, "darwin"); + const shellWithWhitespaceEnv = normalizeCopilotRuntimeEnvironment( + { SHELL: "/bin/bash --noprofile" }, + "darwin", + ); + + assert.equal(relativeShellEnv.SHELL, fallbackShell); + assert.equal(shellWithWhitespaceEnv.SHELL, fallbackShell); + }); + + it("preserves valid POSIX SHELL paths", () => { + const validShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; + assert.ok(validShell); + + const env = normalizeCopilotRuntimeEnvironment({ SHELL: validShell }, "darwin"); + + assert.equal(env.SHELL, validShell); + }); + + it("forces the Copilot POSIX shell spawn backend to avoid node-pty failures", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + assert.equal(env.COPILOT_FEATURE_FLAGS, "SHELL_SPAWN_BACKEND"); + assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, "true"); + }); + + it("preserves existing Copilot feature flags while enabling the shell spawn backend", () => { + const env = normalizeCopilotRuntimeEnvironment( + { COPILOT_FEATURE_FLAGS: "FOCUSED_TOOLS, SHELL_SPAWN_BACKEND, MCP_APPS" }, + "darwin", + ); + + assert.equal(env.COPILOT_FEATURE_FLAGS, "FOCUSED_TOOLS,SHELL_SPAWN_BACKEND,MCP_APPS"); + }); + + it("does not apply POSIX shell normalization on Windows", () => { + const env = normalizeCopilotRuntimeEnvironment({ SHELL: "bash" }, "win32"); + + assert.equal(env.SHELL, "bash"); + assert.equal(env.COPILOT_FEATURE_FLAGS, undefined); + assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, undefined); + }); + + it.layer(NodeServices.layer)("Copilot CLI command resolution", (it) => { + it.effect( + "strips inherited COPILOT_CLI_PATH and uses the local Copilot CLI shim by default", + () => + Effect.gen(function* () { + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "", + serverUrl: "", + customModels: [], + }, + cwd: "/tmp/project", + baseDirectory: "/tmp/t3-copilot-home", + env: { + PATH: "/usr/bin", + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + GITHUB_TOKEN: "github-token", + }, + platform: "darwin", + logLevel: "error", + }); + + const connection = assertStdioConnection(options.connection); + assert.ok(connection.path?.includes("node_modules/.bin/copilot")); + assert.equal(options.workingDirectory, "/tmp/project"); + assert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); + assert.equal(options.logLevel, "error"); + assert.equal(options.mode, "copilot-cli"); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + assert.equal(options.env?.GITHUB_TOKEN, "github-token"); + assert.equal(options.env?.PATH, "/usr/bin"); + }), + ); + + it.effect("resolves the bundled Copilot CLI shim without relying on PATH", () => + Effect.gen(function* () { + const cliPath = yield* resolveBundledCopilotCliPath({ + cwd: "/tmp/project", + env: { PATH: "/usr/bin" }, + platform: "darwin", + }); + + assert.ok(cliPath?.includes("node_modules/.bin/copilot")); + }), + ); + + it.effect("prefers the configured binary path over any inherited CLI path override", () => + Effect.gen(function* () { + const configuredBinaryPath = process.execPath; + + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: configuredBinaryPath, + serverUrl: "", + customModels: [], + }, + env: { + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + }, + platform: "darwin", + }); + + const connection = assertStdioConnection(options.connection); + assert.equal(connection.path, configuredBinaryPath); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + }), + ); + }); + + it("omits the generic signed-in user prefix from authenticated Copilot labels", () => { + const snapshot = authSnapshotFromCopilotSdk({ + isAuthenticated: true, + authType: "user", + host: "https://github.com", + statusMessage: "octocat", + login: "octocat", + }); + + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.auth.type, "user"); + assert.equal(snapshot.auth.label, "@octocat - github.com"); + }); + + it("prefers the richer authenticated status message when it differs from the raw login", () => { + const snapshot = authSnapshotFromCopilotSdk({ + isAuthenticated: true, + authType: "gh-cli", + host: "https://github.com", + statusMessage: "zortos293 (via gh)", + login: "zortos293", + }); + + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.auth.type, "gh-cli"); + assert.equal(snapshot.auth.label, "zortos293 (via gh)"); + }); +}); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts new file mode 100644 index 00000000000..85ddeafaa1c --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.ts @@ -0,0 +1,561 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { + CopilotClient, + RuntimeConnection, + type CopilotClientOptions, + type GetAuthStatusResponse, + type GetStatusResponse, + type ModelInfo, +} from "@github/copilot-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { accessSync, constants as fsConstants, statSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + CopilotSettings, + ModelCapabilities, + ProviderOptionDescriptor, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); + +export const EMPTY_COPILOT_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const COPILOT_REASONING_LABELS: Readonly> = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +} as const; + +const GENERIC_EFFECT_TRY_PROMISE_MESSAGES = new Set([ + "An error occurred in Effect.tryPromise", + "An error occurred in Effect.try", +]); +const COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH"; +const COPILOT_CLI_COMMAND = "copilot"; +const COPILOT_FEATURE_FLAGS_ENV = "COPILOT_FEATURE_FLAGS"; +const COPILOT_SHELL_SPAWN_BACKEND_FLAG = "SHELL_SPAWN_BACKEND"; +const COPILOT_SHELL_SPAWN_BACKEND_EXP_ENV = "COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND"; +const COPILOT_POSIX_SHELL_CANDIDATES = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; + +export class CopilotProbePromiseError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + this.name = "CopilotProbePromiseError"; + } +} + +class CopilotCliPathResolutionError extends Data.TaggedError("CopilotCliPathResolutionError")<{ + readonly message: string; +}> {} + +export function trimOrUndefined(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function toCopilotProbeError(cause: unknown): CopilotProbePromiseError { + return new CopilotProbePromiseError(cause); +} + +function describeCopilotProbeCause(cause: unknown): string { + const seen = new Set(); + let current: unknown = cause; + + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + const message = current.message.trim(); + if (message.length > 0 && !GENERIC_EFFECT_TRY_PROMISE_MESSAGES.has(message)) { + return message; + } + current = current.cause; + } + + if (typeof current === "string") { + return current.trim(); + } + + return ""; +} + +function authTypeLabel(authType: GetAuthStatusResponse["authType"]): string | undefined { + switch (authType) { + case "user": + return undefined; + case "env": + return "Environment token"; + case "gh-cli": + return "GitHub CLI"; + case "hmac": + return "HMAC key"; + case "api-key": + return "API key"; + case "token": + return "Bearer token"; + default: + return undefined; + } +} + +function normalizeAuthLabelPart(value: string | null | undefined): string | undefined { + const trimmed = trimOrUndefined(value); + return trimmed ? trimmed.replace(/^@/, "").toLowerCase() : undefined; +} + +export const createCopilotClient = Effect.fn("createCopilotClient")(function* (input: { + readonly settings: CopilotSettings; + readonly cwd?: string; + readonly baseDirectory?: string; + readonly env?: Record; + readonly platform: NodeJS.Platform; + readonly logLevel?: CopilotClientOptions["logLevel"]; + readonly onListModels?: CopilotClientOptions["onListModels"]; +}): Effect.fn.Return { + return new CopilotClient(yield* buildCopilotClientOptions(input)); +}); + +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + + accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function isValidPosixShellPath(path: string | undefined): path is string { + return !!path && !/\s/.test(path) && isAbsolute(path) && isExecutableFile(path); +} + +function resolvePosixShellPath(currentShell: string | undefined): string | undefined { + if (isValidPosixShellPath(currentShell)) { + return currentShell; + } + + return COPILOT_POSIX_SHELL_CANDIDATES.find(isExecutableFile); +} + +function appendCommaSeparatedValue(value: string | undefined, entry: string): string { + const entries = + value + ?.split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) ?? []; + + return entries.includes(entry) ? entries.join(",") : [...entries, entry].join(","); +} + +export function normalizeCopilotRuntimeEnvironment( + input: Record, + platform: NodeJS.Platform, +): Record { + const env = { ...input }; + + if (platform !== "win32") { + const shellPath = resolvePosixShellPath(env.SHELL); + if (shellPath) { + env.SHELL = shellPath; + } + + env[COPILOT_FEATURE_FLAGS_ENV] = appendCommaSeparatedValue( + env[COPILOT_FEATURE_FLAGS_ENV], + COPILOT_SHELL_SPAWN_BACKEND_FLAG, + ); + env[COPILOT_SHELL_SPAWN_BACKEND_EXP_ENV] = "true"; + } + + return env; +} + +const resolveCopilotCommandPath = ( + command: string, + input: { + readonly env: Record; + readonly platform: NodeJS.Platform; + }, +) => + resolveCommandPath(command, { env: input.env }).pipe( + Effect.provideService(HostProcessPlatform, input.platform), + Effect.provide(NodeServices.layer), + ); + +const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCliPath")( + function* (input: { + readonly settings: CopilotSettings; + readonly env?: Record; + readonly platform: NodeJS.Platform; + }): Effect.fn.Return { + const cliUrl = trimOrUndefined(input.settings.serverUrl); + if (cliUrl) { + return undefined; + } + + const cliPath = trimOrUndefined(input.settings.binaryPath); + if (!cliPath) { + return undefined; + } + + const env = input.env ?? process.env; + return yield* resolveCopilotCommandPath(cliPath, { env, platform: input.platform }).pipe( + Effect.catchTag("CommandResolutionError", () => + Effect.fail( + new CopilotCliPathResolutionError({ + message: `The configured Copilot binary could not be found: ${cliPath}.`, + }), + ), + ), + ); + }, +); + +function candidateDirectoryAncestors(directory: string): ReadonlyArray { + const directories: string[] = []; + let current = directory; + + for (let depth = 0; depth < 8; depth += 1) { + directories.push(current); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return directories; +} + +export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliPath")( + function* (input: { + readonly cwd?: string; + readonly env?: Record; + readonly platform: NodeJS.Platform; + }): Effect.fn.Return { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const candidateRoots = new Set(); + + if (input.cwd) { + candidateRoots.add(input.cwd); + } + candidateRoots.add(process.cwd()); + + for (const directory of candidateDirectoryAncestors(moduleDirectory)) { + candidateRoots.add(directory); + } + + for (const root of candidateRoots) { + const candidate = join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); + const resolved = yield* resolveCopilotCommandPath(candidate, { + env: input.env ?? process.env, + platform: input.platform, + }).pipe(Effect.catchTag("CommandResolutionError", () => Effect.void)); + if (resolved) { + return resolved; + } + } + + return undefined; + }, +); + +export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")(function* (input: { + readonly settings: CopilotSettings; + readonly cwd?: string; + readonly baseDirectory?: string; + readonly env?: Record; + readonly platform: NodeJS.Platform; + readonly logLevel?: CopilotClientOptions["logLevel"]; + readonly onListModels?: CopilotClientOptions["onListModels"]; +}): Effect.fn.Return { + const cliUrl = trimOrUndefined(input.settings.serverUrl); + let env: Record = { ...process.env }; + + if (input.env) { + Object.assign(env, input.env); + } + + delete env[COPILOT_CLI_PATH_ENV]; + env = normalizeCopilotRuntimeEnvironment(env, input.platform); + + const configuredCliPath = yield* validateConfiguredCopilotCliPath({ + settings: input.settings, + env, + platform: input.platform, + }); + const bundledCliPath = + !cliUrl && !configuredCliPath + ? yield* resolveBundledCopilotCliPath({ + ...(input.cwd ? { cwd: input.cwd } : {}), + env, + platform: input.platform, + }) + : undefined; + const cliPath = configuredCliPath ?? bundledCliPath; + + return { + ...(cliUrl + ? { connection: RuntimeConnection.forUri(cliUrl) } + : cliPath + ? { connection: RuntimeConnection.forStdio({ path: cliPath }) } + : {}), + mode: "copilot-cli", + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), + env, + ...(input.logLevel ? { logLevel: input.logLevel } : {}), + ...(input.onListModels ? { onListModels: input.onListModels } : {}), + }; +}); + +export function versionFromCopilotStatus(status: GetStatusResponse): string | null { + return trimOrUndefined(status.version) ?? null; +} + +function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}M`; + } + if (tokens >= 1_000 && tokens % 1_000 === 0) { + return `${tokens / 1_000}K`; + } + return tokens.toLocaleString("en-US"); +} + +function formatContextTierLabel(label: string, tokens: number | undefined): string { + return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 + ? `${label} (${formatTokenCount(tokens)} tokens)` + : label; +} + +type CopilotModelInfoForCapabilities = Pick & { + readonly supportedReasoningEfforts?: ReadonlyArray; + readonly defaultReasoningEffort?: string; + readonly billing?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getPositiveFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function getCopilotContextTierTokenBudgets(model: CopilotModelInfoForCapabilities): { + readonly defaultContextPromptTokens?: number; + readonly longContextPromptTokens?: number; +} { + if (!isRecord(model.billing)) { + return {}; + } + const tokenPrices = model.billing.tokenPrices; + if (!isRecord(tokenPrices)) { + return {}; + } + const longContext = tokenPrices.longContext; + const defaultContextPromptTokens = getPositiveFiniteNumber(tokenPrices.contextMax); + const longContextPromptTokens = isRecord(longContext) + ? getPositiveFiniteNumber(longContext.contextMax) + : undefined; + return { + ...(defaultContextPromptTokens !== undefined ? { defaultContextPromptTokens } : {}), + ...(longContextPromptTokens !== undefined ? { longContextPromptTokens } : {}), + }; +} + +export function capabilitiesFromCopilotModel( + model: CopilotModelInfoForCapabilities, +): ModelCapabilities { + const reasoningOptions = + model.supportedReasoningEfforts?.map((effort) => ({ + id: effort, + label: COPILOT_REASONING_LABELS[effort] ?? effort, + ...(model.defaultReasoningEffort === effort ? { isDefault: true } : {}), + })) ?? []; + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const descriptors: Array = []; + + if (reasoningOptions.length > 0) { + descriptors.push({ + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }); + } + + const contextTierTokenBudgets = getCopilotContextTierTokenBudgets(model); + if (contextTierTokenBudgets.longContextPromptTokens !== undefined) { + descriptors.push({ + id: "contextTier", + label: "Context Window", + type: "select", + options: [ + { + id: "default", + label: formatContextTierLabel( + "Default", + contextTierTokenBudgets.defaultContextPromptTokens, + ), + }, + { + id: "long_context", + label: formatContextTierLabel( + "Long Context", + model.capabilities.limits.max_context_window_tokens, + ), + }, + ], + currentValue: "default", + }); + } + + return createModelCapabilities({ optionDescriptors: descriptors }); +} + +export function modelsFromCopilotSdk(input: { + readonly models: ReadonlyArray; + readonly customModels: ReadonlyArray; +}): ReadonlyArray { + const builtInModels = input.models.map((model) => ({ + slug: model.id.trim(), + name: trimOrUndefined(model.name) ?? model.id.trim(), + isCustom: false, + capabilities: capabilitiesFromCopilotModel(model), + })) satisfies ReadonlyArray; + + return providerModelsFromSettings( + builtInModels, + PROVIDER, + input.customModels, + EMPTY_COPILOT_MODEL_CAPABILITIES, + ); +} + +export function authSnapshotFromCopilotSdk(authStatus: GetAuthStatusResponse): { + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} { + const authType = trimOrUndefined(authStatus.authType); + const authTypeDisplay = authTypeLabel(authStatus.authType); + const fallbackLabel = [ + authTypeDisplay, + authStatus.login ? `@${authStatus.login}` : undefined, + trimOrUndefined(authStatus.host)?.replace(/^https?:\/\//, ""), + ] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join(" - "); + const statusMessageLabel = trimOrUndefined(authStatus.statusMessage); + const normalizedStatusMessage = normalizeAuthLabelPart(statusMessageLabel); + const normalizedLogin = normalizeAuthLabelPart(authStatus.login); + const normalizedAuthTypeDisplay = normalizeAuthLabelPart(authTypeDisplay); + const label = + authStatus.isAuthenticated && + normalizedStatusMessage !== undefined && + normalizedStatusMessage !== normalizedLogin && + normalizedStatusMessage !== normalizedAuthTypeDisplay + ? statusMessageLabel + : fallbackLabel; + + if (!authStatus.isAuthenticated) { + return { + status: "error", + auth: { + status: "unauthenticated", + ...(authType ? { type: authType } : {}), + ...(label ? { label } : {}), + }, + message: + trimOrUndefined(authStatus.statusMessage) ?? + "GitHub Copilot is not authenticated. Sign in with the Copilot CLI or provide a supported token.", + }; + } + + return { + status: "ready", + auth: { + status: "authenticated", + ...(authType ? { type: authType } : {}), + ...(label ? { label } : {}), + }, + }; +} + +export function formatCopilotProbeError(input: { + readonly cause: unknown; + readonly settings: CopilotSettings; +}): { + readonly installed: boolean; + readonly message: string; +} { + const message = describeCopilotProbeCause(input.cause); + const lower = message.toLowerCase(); + const cliUrl = trimOrUndefined(input.settings.serverUrl); + const cliPath = trimOrUndefined(input.settings.binaryPath); + + if (cliUrl) { + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("network") || + lower.includes("timed out") || + lower.includes("timeout") + ) { + return { + installed: true, + message: `Couldn't reach the configured Copilot server at ${cliUrl}. Check that it is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: message || "Failed to connect to the configured Copilot server.", + }; + } + + if ( + lower.includes("enoent") || + lower.includes("spawn") || + lower.includes("not found") || + lower.includes("could not find") || + lower.includes("could not be found") || + lower.includes("not executable") + ) { + return { + installed: false, + message: cliPath + ? `The configured Copilot binary could not be started: ${cliPath}.` + : "The bundled GitHub Copilot CLI could not be started.", + }; + } + + return { + installed: true, + message: message || "GitHub Copilot SDK probe failed.", + }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 095e82f948e..10b34a640b8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5392,6 +5392,71 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("overlays thread metadata events onto websocket shell updates", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-title-race"); + const updatedAt = "2026-04-05T00:00:01.000Z"; + const metadataEvent: OrchestrationEvent = { + sequence: 42, + eventId: EventId.make("event-thread-meta-updated"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: updatedAt, + commandId: CommandId.make("cmd-thread-meta-update"), + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.meta-updated", + payload: { + threadId, + title: "Fresh provider title", + branch: "feature/fresh-title", + worktreePath: "/tmp/fresh-title", + updatedAt, + }, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.make(metadataEvent), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + title: "Stale projection title", + branch: "main", + worktreePath: "/tmp/stale-title", + updatedAt: "2026-04-05T00:00:00.000Z", + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(Stream.runCollect), + ), + ); + + const shellEvent = Array.from(result).find((item) => item.kind === "thread-upserted"); + assert.equal(shellEvent?.kind, "thread-upserted"); + assert.equal(shellEvent?.sequence, metadataEvent.sequence); + if (shellEvent?.kind === "thread-upserted") { + assert.equal(shellEvent.thread.title, "Fresh provider title"); + assert.equal(shellEvent.thread.branch, "feature/fresh-title"); + assert.equal(shellEvent.thread.worktreePath, "/tmp/fresh-title"); + assert.equal(shellEvent.thread.updatedAt, updatedAt); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts new file mode 100644 index 00000000000..3e6fc1bc41c --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -0,0 +1,151 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { beforeEach, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { vi } from "vite-plus/test"; + +import { ServerConfig } from "../config.ts"; +import { makeCopilotTextGeneration } from "./CopilotTextGeneration.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + createdClients: [] as Array<{ + readonly input: { readonly cwd?: string; readonly baseDirectory?: string }; + readonly client: { + readonly start: ReturnType; + readonly stop: ReturnType; + readonly createSession: ReturnType; + }; + }>, + sessions: [] as Array<{ + readonly disconnect: ReturnType; + readonly sendAndWait: ReturnType; + }>, + }; + + return { + state, + reset() { + state.createdClients = []; + state.sessions = []; + }, + }; +}); + +vi.mock("../provider/copilotRuntime.ts", async () => { + const actual = await vi.importActual( + "../provider/copilotRuntime.ts", + ); + + return { + ...actual, + createCopilotClient: vi.fn( + (input: { readonly cwd?: string; readonly baseDirectory?: string }) => { + const start = vi.fn(async () => undefined); + const stop = vi.fn(async () => undefined); + const createSession = vi.fn(async () => { + const sendAndWait = vi.fn(async () => ({ + data: { + content: JSON.stringify({ + subject: "Add change", + body: "", + }), + }, + })); + const disconnect = vi.fn(async () => undefined); + runtimeMock.state.sessions.push({ disconnect, sendAndWait }); + return { + sendAndWait, + disconnect, + }; + }); + + const client = { + start, + stop, + createSession, + }; + runtimeMock.state.createdClients.push({ input, client }); + return Effect.succeed(client); + }, + ), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const defaultCopilotSettings: CopilotSettings = { + enabled: true, + binaryPath: "", + serverUrl: "", + customModels: [], +}; + +const CopilotTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { + it.effect("reuses a started Copilot client across git text generation requests", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + + const first = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-text-generation", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + const second = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-text-generation", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + expect(first.subject).toBe("Add change"); + expect(second.subject).toBe("Add change"); + + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.createdClients[0]?.input.baseDirectory).toBeUndefined(); + expect(runtimeMock.state.sessions).toHaveLength(2); + + const sharedClient = runtimeMock.state.createdClients[0]?.client; + expect(sharedClient?.start).toHaveBeenCalledTimes(1); + expect(sharedClient?.createSession).toHaveBeenCalledTimes(2); + expect(sharedClient?.stop).not.toHaveBeenCalled(); + + expect(runtimeMock.state.sessions[0]?.sendAndWait).toHaveBeenCalledTimes(1); + expect(runtimeMock.state.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); + expect(runtimeMock.state.sessions[1]?.sendAndWait).toHaveBeenCalledTimes(1); + expect(runtimeMock.state.sessions[1]?.disconnect).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("passes the configured Copilot base directory to shared clients", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings, process.env, { + baseDirectory: "/tmp/t3-copilot-home", + }); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-home", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.createdClients[0]?.input.baseDirectory).toBe("/tmp/t3-copilot-home"); + }), + ); +}); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts new file mode 100644 index 00000000000..5c13e5a68bf --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -0,0 +1,470 @@ +import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; +import { Effect, Exit, Fiber, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + type ChatAttachment, + type CopilotSettings, + type ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { createCopilotClient, trimOrUndefined } from "../provider/copilotRuntime.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; + +const COPILOT_TIMEOUT_MS = 180_000; +const COPILOT_TEXT_GENERATION_IDLE_TTL = "30 seconds"; + +type CopilotTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; +type CopilotReasoningEffort = NonNullable; + +interface SharedCopilotTextClientState { + readonly client: CopilotClient; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +interface SharedCopilotTextClientLease { + readonly clientKey: string; + readonly client: CopilotClient; +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +function copilotJsonPrompt(prompt: string, outputSchemaJson: Schema.Top): string { + const schemaDocument = JSON.stringify(toJsonSchemaObject(outputSchemaJson), null, 2); + return `${prompt} + +Return exactly one JSON object matching this schema: +${schemaDocument} + +Do not wrap the JSON in markdown fences or include any other text.`; +} + +function copilotTextGenerationError( + operation: CopilotTextGenerationOperation, + detail: string, + cause?: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function detailFromCause(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; +} + +function copilotTextClientKey(input: { + readonly settings: CopilotSettings; + readonly cwd: string; +}): string { + return JSON.stringify({ + cwd: input.cwd, + binaryPath: trimOrUndefined(input.settings.binaryPath) ?? null, + serverUrl: trimOrUndefined(input.settings.serverUrl) ?? null, + }); +} + +export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")(function* ( + settings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, + options?: { + readonly baseDirectory?: string; + }, +) { + const serverConfig = yield* ServerConfig; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedClientMutex = yield* Semaphore.make(1); + const sharedClients = new Map(); + + const closeSharedClient = (clientKey: string) => + Effect.gen(function* () { + const state = sharedClients.get(clientKey); + if (!state) { + return; + } + + sharedClients.delete(clientKey); + const idleCloseFiber = state.idleCloseFiber; + state.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + yield* Effect.tryPromise({ + try: () => state.client.stop(), + catch: () => undefined, + }).pipe(Effect.ignore); + }); + + const cancelIdleCloseFiber = (state: SharedCopilotTextClientState) => + Effect.gen(function* () { + const idleCloseFiber = state.idleCloseFiber; + state.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = (clientKey: string, state: SharedCopilotTextClientState) => + Effect.gen(function* () { + yield* cancelIdleCloseFiber(state); + const fiber = yield* Effect.sleep(COPILOT_TEXT_GENERATION_IDLE_TTL).pipe( + Effect.andThen( + sharedClientMutex.withPermit( + Effect.gen(function* () { + const current = sharedClients.get(clientKey); + if (!current || current !== state || current.activeRequests > 0) { + return; + } + + current.idleCloseFiber = null; + yield* closeSharedClient(clientKey); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + state.idleCloseFiber = fiber; + }); + + const acquireSharedClient = (input: { + readonly operation: CopilotTextGenerationOperation; + readonly cwd: string; + readonly settings: CopilotSettings; + }): Effect.Effect => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const clientKey = copilotTextClientKey({ + settings: input.settings, + cwd: input.cwd, + }); + + const client = yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const existing = sharedClients.get(clientKey); + if (existing) { + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return existing.client; + } + + const client = yield* createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), + ), + ); + yield* Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }); + + sharedClients.set(clientKey, { + client, + activeRequests: 1, + idleCloseFiber: null, + }); + return client; + }), + ); + + return { clientKey, client }; + }); + + const releaseSharedClient = (lease: SharedCopilotTextClientLease) => + sharedClientMutex.withPermit( + Effect.gen(function* () { + const state = sharedClients.get(lease.clientKey); + if (!state || state.client !== lease.client) { + return; + } + + state.activeRequests = Math.max(0, state.activeRequests - 1); + if (state.activeRequests === 0) { + yield* scheduleIdleClose(lease.clientKey, state); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedClientMutex.withPermit( + Effect.forEach([...sharedClients.keys()], (clientKey) => closeSharedClient(clientKey), { + discard: true, + }), + ), + ); + + const runCopilotJson = (input: { + readonly operation: CopilotTextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }): Effect.Effect => + Effect.gen(function* () { + if (!settings.enabled) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "Copilot is disabled in server settings.", + }); + } + + const fileAttachments = (input.attachments ?? []) + .map((attachment) => { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return path + ? { + type: "file" as const, + path, + displayName: attachment.name, + } + : null; + }) + .filter((attachment): attachment is NonNullable => attachment !== null); + const reasoningEffort = getModelSelectionStringOptionValue( + input.modelSelection, + "reasoningEffort", + ) as CopilotReasoningEffort | undefined; + + // Keep request state isolated per generation call while reusing the + // started SDK client so git helpers do not respawn the Copilot CLI. + const rawContent = yield* Effect.acquireUseRelease( + acquireSharedClient({ + operation: input.operation, + cwd: input.cwd, + settings, + }), + ({ client }) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + client.createSession({ + clientName: "t3-code-git-text", + model: input.modelSelection.model, + ...(reasoningEffort ? { reasoningEffort } : {}), + workingDirectory: input.cwd, + streaming: false, + availableTools: [], + enableConfigDiscovery: false, + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to create Copilot session."), + cause, + ), + }), + (session: CopilotSession) => + Effect.tryPromise({ + try: async () => { + const response = await session.sendAndWait( + { + prompt: copilotJsonPrompt(input.prompt, input.outputSchemaJson), + ...(fileAttachments.length > 0 ? { attachments: fileAttachments } : {}), + }, + COPILOT_TIMEOUT_MS, + ); + return response?.data.content.trim() ?? ""; + }, + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Copilot text generation request failed."), + cause, + ), + }), + (session) => + Effect.tryPromise({ + try: () => session.disconnect(), + catch: () => undefined, + }).pipe(Effect.ignore), + ), + releaseSharedClient, + ); + + if (rawContent.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "Copilot returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawContent)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Copilot returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "Copilot text generation request failed.", + cause, + }), + ), + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CopilotTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...(input.includeBranch && "branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CopilotTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CopilotTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CopilotTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d5d28e638ed..25b8e976e1a 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -10,7 +10,13 @@ import { } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "copilot" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..124765a6c00 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -749,28 +749,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "checkpoint.fallback_from_to_head": input.fallbackFromToHead, }); - let fromRevision: string = input.fromCheckpointRef; + const unavailableDiffError = (detail = "Checkpoint ref is unavailable for diff operation.") => + new VcsProcessExitError({ + operation, + command: "git diff", + cwd: input.cwd, + exitCode: 1, + detail, + }); + + let fromRevision = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); if (input.fallbackFromToHead === true) { - const resolvedFromCommit = yield* resolveCheckpointCommit( - input.cwd, - input.fromCheckpointRef, - ); - if (resolvedFromCommit) { - fromRevision = resolvedFromCommit; - } else { + if (!fromRevision) { const headCommit = yield* resolveHeadCommit(input.cwd); if (!headCommit) { - return yield* new VcsProcessExitError({ - operation, - command: "git diff", - cwd: input.cwd, - exitCode: 1, - detail: "Checkpoint ref is unavailable for diff operation.", - }); + return yield* unavailableDiffError(); } fromRevision = headCommit; } } + if (!fromRevision) { + return yield* unavailableDiffError(); + } + + const toRevision = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); + if (!toRevision) { + return yield* unavailableDiffError(); + } const result = yield* execute({ operation, @@ -783,7 +788,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-textconv", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, - `${input.toCheckpointRef}^{commit}`, + `${toRevision}^{commit}`, ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 175bf3248b8..c793de491ae 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -120,7 +120,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.meta-updated"; } > { return ( @@ -129,7 +130,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.meta-updated" ); } @@ -511,6 +513,28 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), Effect.orElseSucceed(() => Option.none()), ); + case "thread.meta-updated": + return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: { + ...nextThread, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection } + : {}), + ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), + ...(event.payload.worktreePath !== undefined + ? { worktreePath: event.payload.worktreePath } + : {}), + updatedAt: event.payload.updatedAt, + }, + })), + ), + Effect.orElseSucceed(() => Option.none()), + ); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 12781005333..beca055f45a 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -160,6 +160,12 @@ function createBaseServerConfig(): ServerConfig { }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, grok: { enabled: true, binaryPath: "", customModels: [] }, + copilot: { + enabled: true, + binaryPath: "", + serverUrl: "", + customModels: [], + }, opencode: { enabled: true, binaryPath: "", diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index ea1693492f0..0a0009b117c 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,4 +1,4 @@ -import { type ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; import { Gemini, GithubCopilotIcon } from "../Icons"; @@ -80,6 +80,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { } return counts; }, [props.instanceEntries]); + const hasCopilotInstance = props.instanceEntries.some( + (entry) => entry.driverKind === ProviderDriverKind.make("copilot"), + ); useLayoutEffect(() => { const content = sidebarContentRef.current; @@ -285,40 +288,42 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { Gemini — Coming soon - {/* Github Copilot button (coming soon) */} - - - - - } - /> - - Github Copilot — Coming soon - - + {/* GitHub Copilot button (coming soon) */} + {!hasCopilotInstance ? ( + + + + + } + /> + + GitHub Copilot — Coming soon + + + ) : null} ) : null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1952d77d4f4..58960f454fa 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -231,6 +231,23 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { }; } +function buildCopilotProvider(models: ServerProvider["models"]): ServerProvider { + return { + driver: ProviderDriverKind.make("copilot"), + instanceId: ProviderInstanceId.make("copilot"), + displayName: "GitHub Copilot", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + models, + slashCommands: [], + skills: [], + }; +} + function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { return { driver: ProviderDriverKind.make("opencode"), @@ -365,11 +382,72 @@ describe("ProviderModelPicker", () => { await page.getByRole("button").click(); await vi.waitFor(() => { - expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ - "favorites", - "codex", - "claudeAgent", - ]); + const order = getSidebarProviderOrder(); + expect(order[0]).toBe("favorites"); + expect(order.slice(1, 3)).toEqual(["codex", "claudeAgent"]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows only the real copilot provider in the sidebar", async () => { + const providers: ReadonlyArray = [ + TEST_PROVIDERS[0]!, + buildCopilotProvider([ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "gpt-4.1", + name: "GPT-4.1", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + TEST_PROVIDERS[1]!, + ]; + const mounted = await mountPicker({ + activeInstanceId: CLAUDE_INSTANCE_ID, + model: "claude-opus-4-6", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder()).toContain("copilot"); + expect(getSidebarProviderOrder()).not.toContain("github-copilot-coming-soon"); + }); + + await page.getByRole("button", { name: "GitHub Copilot", exact: true }).click(); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("Auto"); + expect(listText).toContain("GPT-4.1"); + expect(listText).not.toContain("Claude Opus 4.6"); }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5a32a780289..752cd541f40 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -45,6 +45,7 @@ type TraitsPersistence = }; const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; +const CONTEXT_WINDOW_DESCRIPTOR_IDS = new Set(["contextWindow", "contextTier"]); function replaceDescriptorCurrentValue( descriptors: ReadonlyArray, @@ -99,7 +100,8 @@ function getSelectedTraits( ); const primarySelectDescriptor = selectDescriptors[0] ?? null; const contextWindowDescriptor = - selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null; + selectDescriptors.find((descriptor) => CONTEXT_WINDOW_DESCRIPTOR_IDS.has(descriptor.id)) ?? + null; const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null; const fastModeDescriptor = booleanDescriptors.find((descriptor) => descriptor.id === "fastMode") ?? null; diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 07eec55de2c..d8a49f09615 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -162,6 +162,27 @@ describe("getComposerProviderState", () => { ); }); + it("does not treat context tier as promptEffort when no reasoning descriptor exists", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("contextTier", [ + { id: "default", label: "Default", isDefault: true }, + { id: "long_context", label: "Long Context" }, + ]), + ]), + prompt: "", + modelOptions: selections(["contextTier", "long_context"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["contextTier", "long_context"]), + }); + }); + it("returns undefined dispatch options when the model declares no descriptors", () => { const state = getComposerProviderState({ provider: PROVIDER, diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d..02738360fcd 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -17,6 +17,8 @@ import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +const PROMPT_EFFORT_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning", "variant"]); + export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; @@ -52,7 +54,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => - descriptor.type === "select", + descriptor.type === "select" && PROMPT_EFFORT_DESCRIPTOR_IDS.has(descriptor.id), ); const primaryValue = getProviderOptionCurrentValue(primarySelectDescriptor ?? null); const promptEffort = typeof primaryValue === "string" ? primaryValue : null; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..1c18c4e1723 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,9 +1,18 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GithubCopilotIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, + [ProviderDriverKind.make("copilot")]: GithubCopilotIcon, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff260..fca97851d26 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { ACPRegistryIcon, Gemini, PiAgentIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -71,11 +71,6 @@ interface ComingSoonDriverOption { } const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ - { - value: ProviderDriverKind.make("githubCopilot"), - label: "Github Copilot", - icon: GithubCopilotIcon, - }, { value: ProviderDriverKind.make("gemini"), label: "Gemini", diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 823f8f968ad..5db8eff683a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -153,6 +153,61 @@ function ProviderAuthEmail(props: { ); } +function ProviderAuthLabel(props: { + readonly label: string | undefined; + readonly type: string | undefined; + readonly separator?: boolean; +}) { + const label = props.label?.trim(); + if (label) { + const parts = label + .split(" - ") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const keyedParts: Array<{ + readonly key: string; + readonly part: string; + readonly separator: boolean; + }> = []; + for (const part of parts) { + const previous = keyedParts.at(-1); + keyedParts.push({ + key: previous ? `${previous.key} - ${part}` : part, + part, + separator: keyedParts.length > 0, + }); + } + + return ( + + {props.separator ? · : null} + {keyedParts.map((part) => ( + + {part.separator ? - : null} + {part.part.startsWith("@") ? ( + + ) : ( + {part.part} + )} + + ))} + + ); + } + + return props.type ? ( + + {props.separator ? · : null} + {props.type} + + ) : null; +} + function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -405,14 +460,13 @@ export function ProviderInstanceCard({ const statusKey: ProviderStatusKey = (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled"); const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; - const rawSummary = getProviderSummary(liveProvider); + const summary = getProviderSummary(liveProvider); const authEmail = liveProvider?.auth.email; - const hasAuthenticatedEmail = - liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); - const authenticatedDetail = hasAuthenticatedEmail - ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) - : null; - const summary = rawSummary; + const authLabel = liveProvider?.auth.label; + const authType = liveProvider?.auth.type; + const isAuthenticated = liveProvider?.auth.status === "authenticated"; + const hasAuthenticatedLabel = Boolean(authLabel?.trim() || authType?.trim()); + const hasAuthenticatedEmail = isAuthenticated && Boolean(authEmail?.trim()); const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; @@ -583,11 +637,13 @@ export function ProviderInstanceCard({ const authRowNode = (

- {hasAuthenticatedEmail ? ( + {isAuthenticated ? ( <> - Authenticated as + + {hasAuthenticatedEmail || hasAuthenticatedLabel ? "Authenticated as" : "Authenticated"} + - {authenticatedDetail ? · {authenticatedDetail} : null} + ) : ( <> diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..6a672785acb 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,13 +1,22 @@ import { ClaudeSettings, CodexSettings, + CopilotSettings, CursorSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GithubCopilotIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -41,6 +50,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenAI, settingsSchema: CodexSettings, }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + icon: GithubCopilotIcon, + settingsSchema: CopilotSettings, + }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c3226884a31..3be3a3255a6 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -61,6 +61,62 @@ describe("contextWindow", () => { }); }); + it("keeps showing the latest reliable context window percentage over newer token-only usage", () => { + const snapshot = deriveLatestContextWindowSnapshot([ + makeActivity("activity-1", "context-window.updated", { + usedTokens: 81_659, + maxTokens: 258_400, + }), + makeActivity("activity-2", "context-window.updated", { + usedTokens: 126, + inputTokens: 120, + outputTokens: 6, + }), + ]); + + expect(snapshot).toMatchObject({ + usedTokens: 81_659, + maxTokens: 258_400, + }); + expect(snapshot?.usedPercentage).toBeCloseTo(31.6, 1); + }); + + it("falls back to the latest token-only usage when no window limit is available", () => { + const snapshot = deriveLatestContextWindowSnapshot([ + makeActivity("activity-1", "context-window.updated", { + usedTokens: 1000, + }), + makeActivity("activity-2", "context-window.updated", { + usedTokens: 1200, + }), + ]); + + expect(snapshot).toMatchObject({ + usedTokens: 1200, + maxTokens: null, + usedPercentage: null, + }); + }); + + it("does not reuse a pre-compaction window limit", () => { + const snapshot = deriveLatestContextWindowSnapshot([ + makeActivity("activity-1", "context-window.updated", { + usedTokens: 81_659, + maxTokens: 258_400, + }), + makeActivity("activity-2", "context-compaction", {}), + makeActivity("activity-3", "context-window.updated", { + usedTokens: 126, + }), + ]); + + expect(snapshot).toMatchObject({ + usedTokens: 126, + maxTokens: null, + usedPercentage: null, + }); + }); + it("formats compact token counts", () => { expect(formatContextWindowTokens(999)).toBe("999"); expect(formatContextWindowTokens(1400)).toBe("1.4k"); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f..43c98faa7d0 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -34,8 +34,12 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Claude"; case "codex": return "Codex"; + case "copilot": + return "GitHub Copilot"; case "cursor": return "Cursor"; + case "grok": + return "Grok"; case "opencode": return "OpenCode"; default: { @@ -47,52 +51,76 @@ export function formatProviderDisplayName(provider: string | null | undefined): } } +function snapshotFromActivity(activity: OrchestrationThreadActivity): ContextWindowSnapshot | null { + const payload = asRecord(activity.payload); + const usedTokens = asFiniteNumber(payload?.usedTokens); + if (usedTokens === null || usedTokens < 0) { + return null; + } + + const maxTokens = asFiniteNumber(payload?.maxTokens); + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; + + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: asFiniteNumber(payload?.inputTokens), + cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), + outputTokens: asFiniteNumber(payload?.outputTokens), + reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), + lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), + lastInputTokens: asFiniteNumber(payload?.lastInputTokens), + lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), + lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), + lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), + toolUses: asFiniteNumber(payload?.toolUses), + durationMs: asFiniteNumber(payload?.durationMs), + compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, + updatedAt: activity.createdAt, + }; +} + export function deriveLatestContextWindowSnapshot( activities: ReadonlyArray, ): ContextWindowSnapshot | null { + let latestTokenOnlySnapshot: ContextWindowSnapshot | null = null; + for (let index = activities.length - 1; index >= 0; index -= 1) { const activity = activities[index]; - if (!activity || activity.kind !== "context-window.updated") { + if (!activity) { continue; } - const payload = asRecord(activity.payload); - const usedTokens = asFiniteNumber(payload?.usedTokens); - if (usedTokens === null || usedTokens < 0) { + if (activity.kind === "context-compaction") { + break; + } + + if (activity.kind !== "context-window.updated") { + continue; + } + + const snapshot = snapshotFromActivity(activity); + if (!snapshot) { continue; } - const maxTokens = asFiniteNumber(payload?.maxTokens); - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; - const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; - - return { - usedTokens, - totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), - maxTokens, - remainingTokens, - usedPercentage, - remainingPercentage, - inputTokens: asFiniteNumber(payload?.inputTokens), - cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), - outputTokens: asFiniteNumber(payload?.outputTokens), - reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), - lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), - lastInputTokens: asFiniteNumber(payload?.lastInputTokens), - lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), - lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), - lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), - toolUses: asFiniteNumber(payload?.toolUses), - durationMs: asFiniteNumber(payload?.durationMs), - compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, - updatedAt: activity.createdAt, - }; + const maxTokens = snapshot.maxTokens ?? null; + if (maxTokens !== null && maxTokens > 0) { + return snapshot; + } + + latestTokenOnlySnapshot ??= snapshot; } - return null; + return latestTokenOnlySnapshot; } export function formatContextWindowTokens(value: number | null): string { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index beb40aadff9..0d8e830524b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1119,6 +1119,38 @@ describe("deriveWorkLogEntries", () => { }); }); + it("uses Copilot command metadata instead of command output detail", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "copilot-command-complete", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + status: "completed", + detail: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + data: { + toolCallId: "tool-command", + toolName: "bash", + command: "git status --short", + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "git status --short", + detail: "M apps/server/src/provider/Layers/CopilotAdapter.ts", + itemType: "command_execution", + toolTitle: "Ran command", + }); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5576ebeffc1..c9dc39594fa 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -32,6 +32,7 @@ export const PROVIDER_OPTIONS: Array<{ pickerSidebarBadge?: "new" | "soon"; }> = [ { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, + { value: ProviderDriverKind.make("copilot"), label: "GitHub Copilot", available: true }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, { value: ProviderDriverKind.make("opencode"), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9a9b05f92f2..6878a11f047 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1601,9 +1601,15 @@ function applyEnvironmentOrchestrationEvent( // diff summary, but don't settle a turn its session is still running. const turnStillRunning = thread.session?.orchestrationStatus === "running" && + thread.session.activeTurnId !== null && thread.session.activeTurnId === event.payload.turnId; + const anotherTurnStillRunning = + thread.session?.orchestrationStatus === "running" && + thread.session.activeTurnId !== null && + thread.session.activeTurnId !== event.payload.turnId; const latestTurn = !turnStillRunning && + !anotherTurnStillRunning && (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? buildLatestTurn({ previous: thread.latestTurn, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 7788eaace48..ac49748b08d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -128,6 +128,7 @@ export const ModelCapabilities = Schema.Struct({ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); +const COPILOT_DRIVER_KIND = ProviderDriverKind.make("copilot"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); @@ -138,6 +139,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, + [COPILOT_DRIVER_KIND]: "gpt-4.1", [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", [CURSOR_DRIVER_KIND]: "auto", [GROK_DRIVER_KIND]: "grok-build", @@ -149,6 +151,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< Record > = { [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL, + [COPILOT_DRIVER_KIND]: "gpt-4.1", [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", [CURSOR_DRIVER_KIND]: "composer-2", [OPENCODE_DRIVER_KIND]: "openai/gpt-5", @@ -165,6 +168,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + [COPILOT_DRIVER_KIND]: { + "4.1": "gpt-4.1", + }, [CLAUDE_DRIVER_KIND]: { opus: "claude-opus-4-8", "opus-4.8": "claude-opus-4-8", @@ -201,6 +207,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< export const PROVIDER_DISPLAY_NAMES: Partial> = { [CODEX_DRIVER_KIND]: "Codex", + [COPILOT_DRIVER_KIND]: "GitHub Copilot", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..b2df56b9451 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -22,6 +22,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.app-server.notification"), Schema.Literal("codex.app-server.request"), Schema.Literal("codex.eventmsg"), + Schema.Literal("copilot.sdk.event"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c94..d3b22754ded 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -202,6 +202,46 @@ export const CodexSettings = makeProviderSettingsSchema( ); export type CodexSettings = typeof CodexSettings.Type; +export const CopilotSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Binary path", + description: + "Optional path to a GitHub Copilot CLI binary. Leave blank to use the SDK-bundled CLI.", + providerSettingsForm: { + placeholder: "Bundled Copilot CLI", + clearWhenEmpty: "omit", + }, + }), + ), + serverUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server URL", + description: "Leave blank to let T3 Code start the SDK-bundled Copilot server.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:4141", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "serverUrl"], + }, +); +export type CopilotSettings = typeof CopilotSettings.Type; + export const ClaudeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -391,6 +431,7 @@ export const ServerSettings = Schema.Struct({ // is removed entirely. providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -447,6 +488,13 @@ const CodexSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CopilotSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + serverUrl: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -492,6 +540,7 @@ export const ServerSettingsPatch = Schema.Struct({ providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), + copilot: Schema.optionalKey(CopilotSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), diff --git a/packages/shared/package.json b/packages/shared/package.json index ad3db08eb2e..7ae243d234a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -67,6 +67,10 @@ "types": "./src/toolActivity.ts", "import": "./src/toolActivity.ts" }, + "./providerToolClassification": { + "types": "./src/providerToolClassification.ts", + "import": "./src/providerToolClassification.ts" + }, "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts new file mode 100644 index 00000000000..05bf10fd1e4 --- /dev/null +++ b/packages/shared/src/providerToolClassification.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; + +import { describe, it } from "@effect/vitest"; + +import { + classifyProviderToolItemType, + classifyProviderToolRequestType, +} from "./providerToolClassification.ts"; + +describe("providerToolClassification", () => { + it("classifies common provider tools consistently", () => { + assert.equal(classifyProviderToolItemType({ toolName: "bash" }), "command_execution"); + assert.equal( + classifyProviderToolItemType({ toolName: "Task_complete" }), + "collab_agent_tool_call", + ); + assert.equal( + classifyProviderToolItemType({ + toolName: "update", + arguments: { + path: "README.md", + content: "new content", + }, + }), + "file_change", + ); + assert.equal(classifyProviderToolItemType({ toolName: "Read" }), "dynamic_tool_call"); + assert.equal(classifyProviderToolItemType({ toolName: "web_fetch" }), "web_search"); + assert.equal(classifyProviderToolItemType({ toolName: "screenshot" }), "image_view"); + assert.equal( + classifyProviderToolItemType({ toolName: "call_tool", mcpServerName: "github" }), + "mcp_tool_call", + ); + }); + + it("classifies approval requests from the same rules", () => { + assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("read_file"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("ReadFile"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval"); + assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval"); + assert.equal(classifyProviderToolRequestType("websearch"), "dynamic_tool_call"); + assert.equal(classifyProviderToolRequestType("web_search"), "dynamic_tool_call"); + assert.equal(classifyProviderToolRequestType("Task"), "dynamic_tool_call"); + }); +}); diff --git a/packages/shared/src/providerToolClassification.ts b/packages/shared/src/providerToolClassification.ts new file mode 100644 index 00000000000..b72de311735 --- /dev/null +++ b/packages/shared/src/providerToolClassification.ts @@ -0,0 +1,141 @@ +import type { CanonicalRequestType, ToolLifecycleItemType } from "@t3tools/contracts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean { + if (!isRecord(arguments_)) { + return false; + } + + const filePathKeys = [ + "path", + "filePath", + "file_path", + "file", + "fileName", + "filename", + "targetFile", + "target_file", + ]; + const editPayloadKeys = [ + "content", + "newContent", + "new_content", + "oldString", + "old_string", + "newString", + "new_string", + "diff", + "patch", + "edits", + ]; + + const hasFilePath = filePathKeys.some((key) => trimmedString(arguments_[key]) !== undefined); + const hasEditPayload = editPayloadKeys.some((key) => arguments_[key] !== undefined); + return hasFilePath && hasEditPayload; +} + +function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("replace") + ) { + return true; + } + + if ( + normalized.includes("create") || + normalized.includes("delete") || + normalized.includes("remove") || + normalized.includes("modify") || + normalized.includes("update") || + normalized.includes("insert") + ) { + return normalized.includes("file") || toolArgumentsLookLikeFileChange(arguments_); + } + + return toolArgumentsLookLikeFileChange(arguments_); +} + +export function isReadOnlyProviderToolName(toolName: string): boolean { + const normalized = toolName.toLowerCase(); + return ( + normalized === "read" || + normalized.includes("read file") || + normalized.includes("read_file") || + normalized.includes("readfile") || + normalized.includes("view") || + normalized.includes("grep") || + normalized.includes("glob") || + normalized.includes("search") + ); +} + +export function classifyProviderToolItemType(input: { + readonly toolName: string; + readonly mcpServerName?: string | undefined; + readonly arguments?: unknown; +}): ToolLifecycleItemType { + const normalized = input.toolName.toLowerCase(); + if (input.mcpServerName || normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if ( + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") || + normalized.includes("agent") || + normalized.includes("delegate") || + normalized.includes("task") + ) { + return "collab_agent_tool_call"; + } + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("exec") || + normalized.includes("command") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if (toolNameImpliesFileChange(input.toolName, input.arguments)) { + return "file_change"; + } + if ( + normalized.includes("websearch") || + normalized.includes("web search") || + normalized.includes("web_search") || + normalized.includes("web") || + normalized.includes("fetch") + ) { + return "web_search"; + } + if (normalized.includes("image") || normalized.includes("screenshot")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +export function classifyProviderToolRequestType(toolName: string): CanonicalRequestType { + const itemType = classifyProviderToolItemType({ toolName }); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : itemType === "web_search" + ? "dynamic_tool_call" + : isReadOnlyProviderToolName(toolName) + ? "file_read_approval" + : "dynamic_tool_call"; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 501422ec25d..668eb39b0b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -395,6 +395,12 @@ importers: '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@github/copilot': + specifier: 1.0.60 + version: 1.0.60 + '@github/copilot-sdk': + specifier: 1.0.0 + version: 1.0.0 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2401,6 +2407,62 @@ packages: '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + '@github/copilot-darwin-arm64@1.0.60': + resolution: {integrity: sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@1.0.60': + resolution: {integrity: sha512-PthhcR6PqbQlT04xQKTElpPSJOrJd65nK/l9Sjmpwtk21RrDKs13DCY/19ubP17updYUWBxp3VNfyfN3DAQKOA==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@1.0.60': + resolution: {integrity: sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@1.0.60': + resolution: {integrity: sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-linuxmusl-arm64@1.0.60': + resolution: {integrity: sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linuxmusl-x64@1.0.60': + resolution: {integrity: sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-sdk@1.0.0': + resolution: {integrity: sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==} + engines: {node: '>=20.0.0'} + + '@github/copilot-win32-arm64@1.0.60': + resolution: {integrity: sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@1.0.60': + resolution: {integrity: sha512-e91ZlFz9J1lkadExLg36oN8Ms/xIa03vAEir3DmyCeYebZ+Y48vdS+BwhQEma+GLoxJUOhzHndCckGnMRfNIbA==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@1.0.60': + resolution: {integrity: sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==} + hasBin: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -9507,6 +9569,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==} @@ -11706,6 +11772,49 @@ snapshots: '@formkit/auto-animate@0.9.0': {} + '@github/copilot-darwin-arm64@1.0.60': + optional: true + + '@github/copilot-darwin-x64@1.0.60': + optional: true + + '@github/copilot-linux-arm64@1.0.60': + optional: true + + '@github/copilot-linux-x64@1.0.60': + optional: true + + '@github/copilot-linuxmusl-arm64@1.0.60': + optional: true + + '@github/copilot-linuxmusl-x64@1.0.60': + optional: true + + '@github/copilot-sdk@1.0.0': + dependencies: + '@github/copilot': 1.0.60 + vscode-jsonrpc: 8.2.1 + zod: 4.4.3 + + '@github/copilot-win32-arm64@1.0.60': + optional: true + + '@github/copilot-win32-x64@1.0.60': + optional: true + + '@github/copilot@1.0.60': + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.60 + '@github/copilot-darwin-x64': 1.0.60 + '@github/copilot-linux-arm64': 1.0.60 + '@github/copilot-linux-x64': 1.0.60 + '@github/copilot-linuxmusl-arm64': 1.0.60 + '@github/copilot-linuxmusl-x64': 1.0.60 + '@github/copilot-win32-arm64': 1.0.60 + '@github/copilot-win32-x64': 1.0.60 + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: hono: 4.12.25 @@ -19353,6 +19462,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 974f3d036f0..2939c873448 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -109,6 +109,23 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); + it("keeps the GitHub Copilot CLI as an explicit staged runtime dependency", () => { + assert.deepStrictEqual( + resolveDesktopRuntimeDependencies( + { + "@github/copilot": "1.0.60", + "@github/copilot-sdk": "1.0.0", + "@t3tools/contracts": "workspace:*", + }, + {}, + ), + { + "@github/copilot": "1.0.60", + "@github/copilot-sdk": "1.0.0", + }, + ); + }); + it("carries only staged dependency patch metadata into staged desktop installs", () => { assert.deepStrictEqual( createStagePnpmConfig(