diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index ccfb9c46742..acd04ecf184 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -266,6 +266,62 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + ), ); +it.live( + "settles session when turn completion arrives without turn id but provider is already settled", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + const turnResponse: TestTurnResponse = { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-missing-turn-id-1", "2026-02-24T10:00:10.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-missing-turn-id-2", "2026-02-24T10:00:10.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Completion without turn id should still settle.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-missing-turn-id-3", "2026-02-24T10:00:10.100Z"), + threadId: THREAD_ID, + status: "completed", + }, + ], + }; + + yield* harness.adapterHarness!.queueTurnResponseForNextSession(turnResponse); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-missing-turn-id-completion", + messageId: "msg-user-missing-turn-id-completion", + text: "Trigger missing completion turn id", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.latestTurn?.state === "completed" && + entry.messages.some( + (message) => + message.role === "assistant" && + message.text === "Completion without turn id should still settle.\n", + ), + ); + assert.equal(thread.session?.status, "ready"); + assert.equal(thread.latestTurn?.state, "completed"); + }), + ), +); + it.live.skipIf(!process.env.CODEX_BINARY_PATH)( "keeps the same Codex provider thread across runtime mode switches", () => diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..1b527825128 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,8 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..817993e3604 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -53,6 +53,7 @@ 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 * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; @@ -79,11 +80,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 = () => @@ -249,7 +251,8 @@ describe("CheckpointReactor", () => { | OrchestrationEngineService | CheckpointReactor | CheckpointStore.CheckpointStore - | ProjectionSnapshotQuery, + | ProjectionSnapshotQuery + | WorkspaceEntries.WorkspaceEntries, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -279,6 +282,7 @@ describe("CheckpointReactor", () => { readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; + readonly rollbackConversation?: ProviderServiceShape["rollbackConversation"]; readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); @@ -288,6 +292,7 @@ describe("CheckpointReactor", () => { options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, options?.providerName ?? ProviderDriverKind.make("codex"), + options?.rollbackConversation, ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -350,6 +355,9 @@ describe("CheckpointReactor", () => { const checkpointStore = await runtime.runPromise( Effect.service(CheckpointStore.CheckpointStore), ); + const workspaceEntries = await runtime.runPromise( + Effect.service(WorkspaceEntries.WorkspaceEntries), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -415,6 +423,8 @@ describe("CheckpointReactor", () => { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, + checkpointStore, + workspaceEntries, cwd, drain, }; @@ -969,6 +979,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(NodeFS.readFileSync(NodePath.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"; @@ -1038,11 +1123,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(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), + ).toBe(true); + }); + + it("refreshes 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 refresh = vi + .spyOn(harness.workspaceEntries, "refresh") + .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(refresh).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"), @@ -1060,7 +1325,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"), @@ -1074,7 +1339,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"), @@ -1089,7 +1354,7 @@ describe("CheckpointReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-sequenced-revert-request-1"), @@ -1098,7 +1363,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"), @@ -1125,7 +1390,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 3ba244ddf2c..7d7e81ac0b3 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.refresh(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; + } + // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. yield* workspaceEntries.refresh(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..814c47125ba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -741,7 +741,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: event.payload.session.activeTurnId, + latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId, 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 ce464565dc5..b104afccb88 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; }) { @@ -309,7 +311,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, @@ -368,7 +376,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), ); @@ -516,6 +530,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"; @@ -607,6 +778,148 @@ 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("replaces provider-expanded first prompt titles that match mobile title seeds", 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 restarting the session and..."; + 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-mobile-seed"), + threadId: ThreadId.make("thread-1"), + title: fullPromptTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-mobile-seed"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-mobile-seed"), + 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("does not replace user-edited titles that extend a truncated seed", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-edited-truncated-seed"), + threadId: ThreadId.make("thread-1"), + title: `${seededTitle} (urgent)`, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-edited-truncated-seed"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-edited-truncated-seed"), + 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.sendTurn.mock.calls.length === 1); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + }); + it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -661,6 +974,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"; @@ -1862,7 +2234,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -1880,7 +2252,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -1901,7 +2273,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -1957,7 +2329,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -1975,7 +2347,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2008,7 +2380,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"), @@ -2057,7 +2429,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"), @@ -2076,7 +2448,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..5deaba24d33 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,17 @@ 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; + } + + const currentLooksUserEditedFromTruncatedSeed = + trimmedTitleSeed.endsWith("...") && trimmedCurrentTitle.includes("..."); + return ( + trimmedCurrentTitle === trimmedTitleSeed || + (!currentLooksUserEditedFromTruncatedSeed && + truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed) + ); } function findProviderAdapterRequestError( @@ -666,7 +675,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, @@ -711,13 +719,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 +794,24 @@ 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, + 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 +1071,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 +1095,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 001ba388949..616eced0d37 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -133,6 +133,9 @@ function createProviderServiceHarness() { } runtimeSessions.push(session); }; + const clearSessions = (): void => { + runtimeSessions.length = 0; + }; const normalizeLegacyEvent = (event: LegacyProviderRuntimeEvent): ProviderRuntimeEvent => { if (isLegacyTurnCompletedEvent(event)) { @@ -157,6 +160,7 @@ function createProviderServiceHarness() { service, emit, setSession, + clearSessions, }; } @@ -314,6 +318,7 @@ describe("ProviderRuntimeIngestion", () => { readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, + clearProviderSessions: provider.clearSessions, drain, }; } @@ -360,6 +365,144 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("settles unscoped turn completion while provider reports the active turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-missing-completion-id"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-missing-completion-id"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-missing-completion-id", + ); + + harness.setProviderSession({ + provider: ProviderDriverKind.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + activeTurnId: asTurnId("turn-missing-completion-id"), + createdAt: now, + updatedAt: now, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-missing-completion-id"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.100Z", + status: "completed", + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.latestTurn?.state === "completed", + ); + expect(thread.latestTurn?.turnId).toBe("turn-missing-completion-id"); + }); + + it("settles unscoped turn completion after provider clears the active turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-provider-cleared"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-provider-cleared"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-provider-cleared", + ); + + harness.setProviderSession({ + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + createdAt: now, + updatedAt: now, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-provider-cleared"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.100Z", + status: "completed", + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.latestTurn?.state === "completed", + ); + expect(thread.latestTurn?.turnId).toBe("turn-provider-cleared"); + }); + + it("rejects unscoped turn completion when no provider session exists", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-sessionless-completion"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-sessionless-completion"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-sessionless-completion", + ); + + harness.clearProviderSessions(); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-sessionless"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.100Z", + status: "completed", + }); + + await harness.drain(); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.session?.activeTurnId === "turn-sessionless-completion", + ); + expect(thread.session?.status).toBe("running"); + expect(thread.latestTurn?.state).toBe("running"); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; @@ -2665,6 +2808,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..5b65ccbb4fc 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 []; @@ -1149,10 +1216,16 @@ const make = Effect.gen(function* () { } as const; }); + const getProviderSessionForThread = Effect.fn("getProviderSessionForThread")(function* ( + threadId: ThreadId, + ) { + const sessions = yield* providerService.listSessions(); + return sessions.find((entry) => entry.threadId === threadId); + }); + const getExpectedProviderTurnIdForThread = Effect.fn("getExpectedProviderTurnIdForThread")( function* (threadId: ThreadId) { - const sessions = yield* providerService.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); + const session = yield* getProviderSessionForThread(threadId); return session?.activeTurnId; }, ); @@ -1221,10 +1294,21 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const lifecycleEventTurnId = eventTurnId; + const providerSessionForUnscopedCompletion = + event.type === "turn.completed" && eventTurnId === undefined + ? yield* getProviderSessionForThread(thread.id) + : undefined; + const providerActiveTurnId = providerSessionForUnscopedCompletion?.activeTurnId; + const unscopedCompletionHasNoProviderSession = + event.type === "turn.completed" && + eventTurnId === undefined && + providerSessionForUnscopedCompletion === undefined; const conflictsWithActiveTurn = - activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); - const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + activeTurnId !== null && + lifecycleEventTurnId !== undefined && + !sameId(activeTurnId, lifecycleEventTurnId); // A turn.started that conflicts with the active turn is legitimate when // the server itself has a turn start pending for this thread AND the @@ -1255,12 +1339,18 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": - if (conflictsWithActiveTurn || missingTurnForActiveTurn) { + if (conflictsWithActiveTurn) { return false; } + if (activeTurnId !== null && lifecycleEventTurnId === undefined) { + return ( + !unscopedCompletionHasNoProviderSession && + (providerActiveTurnId === undefined || sameId(activeTurnId, providerActiveTurnId)) + ); + } // Only the active turn may close the lifecycle state. - if (activeTurnId !== null && eventTurnId !== undefined) { - return sameId(activeTurnId, eventTurnId); + if (activeTurnId !== null && lifecycleEventTurnId !== undefined) { + return sameId(activeTurnId, lifecycleEventTurnId); } // If no active turn is tracked, accept completion scoped to this thread. return true; @@ -1543,7 +1633,7 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; - const turnId = toTurnId(event.turnId); + const turnId = toTurnId(event.turnId) ?? (shouldApplyThreadLifecycle ? activeTurnId : null); if (turnId) { const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); yield* Effect.forEach( diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 00000000000..a98a68ab370 --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,161 @@ +/** + * 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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import type * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +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 + | Context.Service.Identifier + | 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..57a419e78e3 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,4530 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTimersPromises 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 * as TestClock from "effect/testing/TestClock"; +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(() => NodeTimersPromises.setTimeout(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, + turnEndIdleFallbackDelayMs: 25, + }), +).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) => { + NodeAssert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + NodeAssert.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", + }); + + NodeAssert.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) => { + NodeAssert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + NodeAssert.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", + }); + + NodeAssert.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) => { + NodeAssert.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, + }, + ); + NodeAssert.deepStrictEqual(shellResult, { kind: "reject" }); + NodeAssert.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", + }); + + NodeAssert.equal(session.provider, "copilot"); + NodeAssert.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) => { + NodeAssert.ok(config.onUserInputRequest); + const response = await config.onUserInputRequest( + { + question: "How should Copilot continue?", + choices: ["Continue"], + allowFreeform: true, + }, + { sessionId: runtimeMock.state.lastSession.sessionId }, + ); + NodeAssert.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", + }); + + NodeAssert.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); + NodeAssert.ok(config?.onEvent); + NodeAssert.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, + ); + } + NodeAssert.equal(requested?.type, "user-input.requested"); + if (requested?.type === "user-input.requested") { + NodeAssert.equal(requested.providerRefs?.providerRequestId, requestId); + } + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { + answer: "Use a custom answer", + }); + const response = yield* Effect.promise(() => responsePromise); + NodeAssert.deepStrictEqual(response, { + answer: "Use a custom answer", + wasFreeform: true, + }); + + 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); + + NodeAssert.equal(resolved?.type, "user-input.resolved"); + if (resolved?.type === "user-input.resolved") { + NodeAssert.equal(resolved.providerRefs?.providerRequestId, requestId); + NodeAssert.deepStrictEqual(resolved.payload.answers, { + answer: "Use a custom answer", + }); + NodeAssert.equal("wasFreeform" in resolved.payload.answers, false); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("coerces fixed-choice Copilot user input to an allowed answer", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-user-input-fixed-choice"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onUserInputRequest); + const requestId = "user-input-fixed-choice"; + const request = { + question: "How should Copilot continue?", + choices: ["Use default", "Stop"], + allowFreeform: false, + }; + const responsePromise = Promise.resolve( + config.onUserInputRequest(request, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-user-input-fixed-choice", + timestamp, + parentId: null, + type: "user_input.requested", + data: { + requestId, + ...request, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { + answer: "Unlisted answer", + }); + + const response = yield* Effect.promise(() => responsePromise); + NodeAssert.deepStrictEqual(response, { + answer: "Use default", + wasFreeform: 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); + NodeAssert.equal(config?.model, "claude-sonnet-4.6"); + NodeAssert.equal(config?.reasoningEffort, "high"); + NodeAssert.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", + }, + }); + + NodeAssert.equal(runtimeMock.state.resumeSessionCalls.length, 1); + NodeAssert.equal( + runtimeMock.state.resumeSessionCalls[0]?.sessionId, + "missing-copilot-session", + ); + NodeAssert.equal(runtimeMock.state.createSessionConfigs.length, 1); + NodeAssert.equal(runtimeMock.state.createSessionConfigs[0]?.sessionId, threadId); + NodeAssert.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" }], + }, + }); + + NodeAssert.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); + NodeAssert.ok(config?.onEvent); + NodeAssert.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); + NodeAssert.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, + ); + } + NodeAssert.equal(requestResolved?.type, "request.resolved"); + if (requestResolved?.type === "request.resolved") { + NodeAssert.equal(requestResolved.payload.requestType, "command_execution_approval"); + NodeAssert.equal(requestResolved.payload.decision, "acceptForSession"); + NodeAssert.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, + ); + NodeAssert.equal(resolvedEvents.length, 1); + + const duplicateReply = yield* Effect.flip( + adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "acceptForSession"), + ); + NodeAssert.match(duplicateReply.message, /Unknown pending permission request/); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns SDK session approval without path prompt approval for reads", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-read-permission-accept-for-session"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "read", + path: "README.md", + intention: "Read project docs", + }; + const requestId = "permission-read-session-approval"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-read-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); + NodeAssert.deepStrictEqual(result, { kind: "approve-for-session" }); + + 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); + NodeAssert.ok(config?.onEvent); + NodeAssert.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); + NodeAssert.deepStrictEqual(result, { + kind: "approve-for-session", + domain: "docs.github.com", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("renders Copilot Task_complete output as assistant text instead of a tool call", () => + 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); + NodeAssert.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-empty-task-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-task-complete-empty-success", + toolName: "Task_complete", + arguments: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-empty-task-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-task-complete-empty-success", + success: true, + result: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-failed-task-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-task-complete-failure", + toolName: "Task_complete", + arguments: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-failed-task-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-task-complete-failure", + success: false, + error: { + message: "Task completion failed", + }, + }, + } 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); + NodeAssert.ok(turnSnapshot); + const assistantItem = turnSnapshot.items.find( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + NodeAssert.deepStrictEqual(assistantItem, { + type: "assistant_message", + messageId: `copilot-task-completion-${String(turn.turnId)}`, + content: resultText, + }); + NodeAssert.equal( + turnSnapshot.items.some( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "tool_execution", + ), + false, + ); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const fallbackDelta = runtimeEvents.find( + (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + NodeAssert.equal(fallbackDelta?.type, "content.delta"); + if (fallbackDelta?.type === "content.delta") { + NodeAssert.equal( + String(fallbackDelta.itemId), + `copilot-task-completion-${String(turn.turnId)}`, + ); + NodeAssert.deepStrictEqual(fallbackDelta.payload, { + streamKind: "assistant_text", + delta: resultText, + }); + } + const taskCompleteToolIds = new Set([ + "copilot-tool-tool-task-complete", + "copilot-tool-tool-task-complete-empty-success", + "copilot-tool-tool-task-complete-failure", + ]); + NodeAssert.equal( + runtimeEvents.some( + (event) => + (event.type === "item.started" || event.type === "item.completed") && + taskCompleteToolIds.has(String(event.itemId)), + ), + false, + ); + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits user interrupt aborts from the SDK abort event", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-interrupt-sdk-abort-source"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "stop this turn", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + yield* adapter.interruptTurn(threadId, turn.turnId); + NodeAssert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); + yield* waitForSdkEventQueue(); + NodeAssert.equal(runtimeEvents.filter((event) => event.type === "turn.aborted").length, 0); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-abort", + timestamp, + parentId: null, + type: "abort", + data: { + reason: "user_initiated", + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const abortedEvents = runtimeEvents.filter((event) => event.type === "turn.aborted"); + NodeAssert.equal(abortedEvents.length, 1); + const aborted = abortedEvents[0]; + NodeAssert.equal(aborted?.type, "turn.aborted"); + if (aborted?.type === "turn.aborted") { + NodeAssert.equal(String(aborted.turnId), String(turn.turnId)); + NodeAssert.equal(aborted.payload.reason, "user_initiated"); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(completed.payload.state, "cancelled"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes an ended Copilot turn before attributing queued follow-up output", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-queued-follow-up-after-turn-end"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + 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); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-first-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-first-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + turnId: "sdk-turn-first", + messageId: "message-first", + content: "First response.", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-first-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-first", + }, + } as SessionEvent); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "follow-up prompt", + attachments: [], + }); + yield* waitForSdkEventQueue(); + NodeAssert.deepStrictEqual( + runtimeEvents + .filter((event) => event.type === "turn.completed") + .map((event) => String(event.turnId)), + [String(firstTurn.turnId)], + ); + + emit({ + id: "evt-copilot-second-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-second-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + turnId: "sdk-turn-second", + messageId: "message-second", + content: "Second response.", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-second-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-second", + }, + } as SessionEvent); + yield* TestClock.adjust("25 millis"); + + for ( + let attempt = 0; + attempt < 20 && runtimeEvents.filter((event) => event.type === "turn.completed").length < 2; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const completedTurnIds = runtimeEvents + .filter((event) => event.type === "turn.completed") + .map((event) => String(event.turnId)); + NodeAssert.deepStrictEqual(completedTurnIds, [ + String(firstTurn.turnId), + String(secondTurn.turnId), + ]); + + const thread = yield* adapter.readThread(threadId); + const firstSnapshot = thread.turns.find((entry) => entry.id === firstTurn.turnId); + const secondSnapshot = thread.turns.find((entry) => entry.id === secondTurn.turnId); + NodeAssert.ok(firstSnapshot); + NodeAssert.ok(secondSnapshot); + NodeAssert.deepStrictEqual(firstSnapshot.items, [ + { + type: "assistant_message", + messageId: "message-first", + content: "First response.", + }, + ]); + NodeAssert.deepStrictEqual(secondSnapshot.items, [ + { + type: "assistant_message", + messageId: "message-second", + content: "Second response.", + }, + ]); + + const sessions = yield* adapter.listSessions(); + NodeAssert.equal(sessions.at(0)?.status, "ready"); + NodeAssert.equal(sessions.at(0)?.activeTurnId, undefined); + + 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); + NodeAssert.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); + NodeAssert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + NodeAssert.deepStrictEqual(assistantItems, []); + NodeAssert.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); + NodeAssert.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: { + kind: "execute", + 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); + NodeAssert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + NodeAssert.deepStrictEqual(assistantItems, []); + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + false, + ); + + const startedTool = runtimeEvents.find( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-read-file", + ); + NodeAssert.equal(startedTool?.type, "item.started"); + if (startedTool?.type === "item.started") { + NodeAssert.ok( + startedTool.payload.data !== null && + typeof startedTool.payload.data === "object" && + !Array.isArray(startedTool.payload.data), + ); + const data = startedTool.payload.data as Record; + NodeAssert.equal(data.kind, "read"); + } + + 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); + NodeAssert.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); + NodeAssert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + NodeAssert.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); + NodeAssert.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); + + NodeAssert.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); + NodeAssert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + NodeAssert.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); + NodeAssert.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); + + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits an active turn diff 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); + NodeAssert.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); + + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.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", + ); + NodeAssert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("classifies terminal apply_patch tool calls as file changes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-terminal-apply-patch-file-change"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "edit the docs through terminal apply_patch", + 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); + NodeAssert.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-terminal-patch-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-terminal-patch", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-terminal-patch-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-terminal-patch", + toolName: "run_in_terminal", + arguments: { + command: `apply_patch <<'PATCH'\n${patch}\nPATCH`, + kind: "execute", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-terminal-patch-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-terminal-patch", + success: true, + result: { + content: `${patch}\n`, + }, + }, + } 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); + + const startedTool = runtimeEvents.find( + (event) => + event.type === "item.started" && + String(event.itemId) === "copilot-tool-tool-terminal-patch", + ); + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + String(event.itemId) === "copilot-tool-tool-terminal-patch", + ); + + NodeAssert.equal(startedTool?.type, "item.started"); + if (startedTool?.type === "item.started") { + NodeAssert.equal(startedTool.payload.itemType, "file_change"); + NodeAssert.equal(startedTool.payload.title, "Applied patch"); + NodeAssert.ok( + startedTool.payload.data !== null && + typeof startedTool.payload.data === "object" && + !Array.isArray(startedTool.payload.data), + ); + NodeAssert.equal("command" in startedTool.payload.data, false); + const data = startedTool.payload.data as Record; + NodeAssert.equal(data.kind, "edit"); + } + NodeAssert.equal(completedTool?.type, "item.completed"); + if (completedTool?.type === "item.completed") { + NodeAssert.equal(completedTool.payload.itemType, "file_change"); + NodeAssert.equal(completedTool.payload.title, "Applied patch"); + NodeAssert.ok( + completedTool.payload.data !== null && + typeof completedTool.payload.data === "object" && + !Array.isArray(completedTool.payload.data), + ); + NodeAssert.equal("command" in completedTool.payload.data, false); + const data = completedTool.payload.data as Record; + NodeAssert.equal(data.kind, "edit"); + } + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.equal(diffEvent.payload.unifiedDiff, patch); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits turn diffs when apply-patch output is only in the command", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-terminal-patch-command-only"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "apply a patch", + 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); + NodeAssert.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-terminal-patch-command-only-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-terminal-patch-command-only" }, + } as SessionEvent); + emit({ + id: "evt-copilot-terminal-patch-command-only-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-terminal-patch-command-only", + toolName: "run_in_terminal", + arguments: { + command: `apply_patch <<'PATCH'\n${patch}\nPATCH`, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-terminal-patch-command-only-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-terminal-patch-command-only", + success: true, + result: { content: "" }, + }, + } 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); + + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.equal(diffEvent.payload.unifiedDiff, patch); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "does not emit an active turn diff when a Copilot command tool returns shell 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); + NodeAssert.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); + + NodeAssert.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"))); + NodeAssert.deepStrictEqual(parserErrorCalls, []); + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + String(event.itemId) === "copilot-tool-tool-command", + ); + NodeAssert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + } + }), + ); + + it.effect( + "emits an active turn diff when a Copilot command tool returns git unified diff output", + () => + 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); + NodeAssert.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); + + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits 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); + NodeAssert.ok(config?.onEvent); + NodeAssert.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 Extract; + + 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, + ); + } + NodeAssert.equal(opened?.type, "request.opened"); + if (opened?.type === "request.opened") { + NodeAssert.equal(opened.payload.requestType, "file_change_approval"); + NodeAssert.equal(String(opened.turnId), String(turn.turnId)); + } + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const approvalResult = yield* Effect.promise(() => resultPromise); + NodeAssert.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); + + const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(diffUpdated.payload, { + unifiedDiff: permissionRequest.diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits turn diff when Copilot completes write permission with location approval", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-write-permission-location-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); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "permission-write-readme-location"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-write-readme-location", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as Extract; + + emit({ + id: "evt-copilot-write-location-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-write-location" }, + } as SessionEvent); + void config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + emit({ + id: "evt-copilot-write-location-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + yield* waitForSdkEventQueue(); + + emit({ + id: "evt-copilot-write-location-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result: { kind: "approve-for-location" }, + }, + } as unknown as SessionEvent); + + let diffUpdated: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffUpdated === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(diffUpdated.payload, { + unifiedDiff: permissionRequest.diff.trim(), + }); + } + + 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); + NodeAssert.ok(config?.onEvent); + NodeAssert.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, + ); + } + NodeAssert.equal(opened?.type, "request.opened"); + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const result = yield* Effect.promise(() => resultPromise); + NodeAssert.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); + NodeAssert.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); + + NodeAssert.equal(titleEvent?.type, "thread.metadata.updated"); + if (titleEvent?.type === "thread.metadata.updated") { + NodeAssert.equal(titleEvent.threadId, threadId); + NodeAssert.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); + NodeAssert.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); + + NodeAssert.equal(planEvent?.type, "turn.plan.updated"); + if (planEvent?.type === "turn.plan.updated") { + NodeAssert.equal(planEvent.threadId, threadId); + NodeAssert.equal(String(planEvent.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(planEvent.payload, { + explanation: "Copilot Tasks", + plan: [ + { step: "Exploring provider events", status: "inProgress" }, + { step: "Running tests", status: "completed" }, + { step: "Reviewing implementation (failed)", status: "pending" }, + ], + }); + } + const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); + NodeAssert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ + "task-explore-1", + "task-review-1", + "task-shell-1", + ]); + const runningProgress = runtimeEvents.find( + (event) => + event.type === "task.progress" && String(event.payload.taskId) === "task-explore-1", + ); + NodeAssert.equal(runningProgress?.type, "task.progress"); + if (runningProgress?.type === "task.progress") { + NodeAssert.equal(runningProgress.payload.description, "Exploring provider events"); + NodeAssert.equal(runningProgress.payload.summary, "Task running"); + } + const completedTasks = runtimeEvents.filter((event) => event.type === "task.completed"); + NodeAssert.deepStrictEqual( + completedTasks.map((event) => [String(event.payload.taskId), event.payload.status]).sort(), + [ + ["task-review-1", "failed"], + ["task-shell-1", "completed"], + ], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("tracks Copilot background task status changes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-task-status-changes"); + + 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(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [ + { + type: "agent", + id: "task-status-1", + toolCallId: "tool-task-status-1", + description: "Inspect implementation", + status: "running", + startedAt: "2026-06-11T12:00:00.000Z", + agentType: "explore", + prompt: "Inspect implementation", + }, + ], + }); + config.onEvent({ + id: "evt-copilot-background-task-status-running", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "task.progress" && String(event.payload.taskId) === "task-status-1", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [ + { + type: "agent", + id: "task-status-1", + toolCallId: "tool-task-status-1", + description: "Inspect implementation", + status: "completed", + startedAt: "2026-06-11T12:00:00.000Z", + completedAt: "2026-06-11T12:00:03.000Z", + agentType: "explore", + prompt: "Inspect implementation", + result: "Inspection completed", + }, + ], + }); + config.onEvent({ + id: "evt-copilot-background-task-status-completed", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const startedEvents = runtimeEvents.filter( + (event) => + event.type === "task.started" && String(event.payload.taskId) === "task-status-1", + ); + const completedEvent = runtimeEvents.find( + (event) => + event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", + ); + NodeAssert.equal(startedEvents.length, 1); + NodeAssert.equal(completedEvent?.type, "task.completed"); + if (completedEvent?.type === "task.completed") { + NodeAssert.equal(completedEvent.turnId, turn.turnId); + NodeAssert.equal(completedEvent.payload.status, "completed"); + NodeAssert.equal(completedEvent.payload.summary, "Inspection completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps Copilot todo tool input to T3 plan updates", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-todo-tool-plan-update"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "track todos", + 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); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-todo-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-todo-tool", + }, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-todo-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-todo-write", + toolName: "TodoWrite", + arguments: { + todos: [ + { content: "Inspect adapter", status: "completed" }, + { content: "Wire task events", status: "in_progress" }, + { content: "Run validation", status: "pending" }, + ], + }, + turnId: "sdk-turn-todo-tool", + }, + } 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); + + NodeAssert.equal(planEvent?.type, "turn.plan.updated"); + if (planEvent?.type === "turn.plan.updated") { + NodeAssert.equal(String(planEvent.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(planEvent.payload, { + explanation: "Copilot Todos", + plan: [ + { step: "Inspect adapter", status: "completed" }, + { step: "Wire task events", status: "inProgress" }, + { step: "Run validation", 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); + NodeAssert.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); + + NodeAssert.equal(markerEvent?.type, "session.state.changed"); + NodeAssert.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); + NodeAssert.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); + + NodeAssert.equal(markerEvent?.type, "session.state.changed"); + NodeAssert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + NodeAssert.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); + NodeAssert.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 started: ProviderRuntimeEvent | undefined; + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + started ??= runtimeEvents.find( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", + ); + completed = runtimeEvents.find( + (event) => + event.type === "item.completed" && String(event.itemId) === "copilot-tool-tool-command", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(started?.type, "item.started"); + if (started?.type === "item.started") { + NodeAssert.equal(started.payload.itemType, "command_execution"); + NodeAssert.equal(started.payload.title, "Ran command: git status --short"); + NodeAssert.equal(started.payload.detail, "git status --short"); + } + + NodeAssert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + NodeAssert.equal(completed.payload.itemType, "command_execution"); + NodeAssert.equal(completed.payload.title, "Ran command: git status --short"); + NodeAssert.equal( + completed.payload.detail, + "M apps/server/src/provider/Layers/CopilotAdapter.ts", + ); + NodeAssert.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); + NodeAssert.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); + emit({ + id: "evt-copilot-idle-after-bad-event", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } 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"); + NodeAssert.equal(runtimeError, undefined); + NodeAssert.equal(toolProgress, undefined); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(String(completed.turnId), String(turn.turnId)); + NodeAssert.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); + NodeAssert.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"); + NodeAssert.equal(runtimeError?.type, "runtime.error"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(completed.payload.state, "failed"); + NodeAssert.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); + NodeAssert.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), + ); + NodeAssert.equal(completions.length, 1); + NodeAssert.equal(completions[0]?.type, "turn.completed"); + if (completions[0]?.type === "turn.completed") { + NodeAssert.equal(completions[0].payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes a final assistant turn when Copilot omits session idle", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-final-turn-end-without-idle"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "complete after final message", + 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); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-final-turn-end-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-final-turn-end", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-final-turn-end-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-final-turn-end", + content: "Finished without a session idle event.", + turnId: "sdk-turn-final-turn-end", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-final-turn-end-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-final-turn-end", + }, + } as SessionEvent); + yield* TestClock.adjust("25 millis"); + + 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 completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(completions.length, 1); + NodeAssert.equal(completions[0]?.type, "turn.completed"); + if (completions[0]?.type === "turn.completed") { + NodeAssert.equal(completions[0].payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps one T3 turn active across Copilot SDK loops until final output", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-multi-sdk-loop-before-idle"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "continue through multiple sdk loops", + 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); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-multi-loop-first-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-multi-loop-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-first-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-multi-loop-first", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "turn.completed"), + false, + ); + + emit({ + id: "evt-copilot-multi-loop-second-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-multi-loop-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-multi-loop-second", + content: "Finished after the second loop.", + turnId: "sdk-turn-multi-loop-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-second-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-multi-loop-second", + }, + } as SessionEvent); + yield* TestClock.adjust("25 millis"); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + emit({ + id: "evt-copilot-multi-loop-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const messageCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.itemId === "copilot-message-message-multi-loop-second", + ); + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(messageCompleted?.type, "item.completed"); + NodeAssert.equal(String(messageCompleted?.turnId), String(turn.turnId)); + NodeAssert.equal(completions.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores unmapped sdk turn starts without synthesizing a turn id", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-unmapped-sdk-turn-start"); + + 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); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-unmapped-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-unmapped", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const runtimeWarning = runtimeEvents.find((event) => event.type === "runtime.warning"); + const runningState = runtimeEvents.find( + (event) => event.type === "session.state.changed" && event.payload.state === "running", + ); + NodeAssert.equal(runtimeWarning, undefined); + NodeAssert.equal(runningState, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not remap an unmapped sdk turn_start to the latest completed turn", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-unmapped-sdk-turn-start-after-completion"); + + 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); + NodeAssert.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-unmapped-after-complete-first-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-unmapped-after-complete-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-unmapped-after-complete-first-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-unmapped-after-complete-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-unmapped-after-complete-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(); + } + + const eventsBeforeSecondUnmappedStart = runtimeEvents.length; + + emit({ + id: "evt-copilot-unmapped-after-complete-second-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-unmapped-after-complete-second", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const postSecondStartEvents = runtimeEvents.slice(eventsBeforeSecondUnmappedStart); + + const staleRunningState = postSecondStartEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.state === "running" && + String(event.turnId) === String(firstTurn.turnId), + ); + const runtimeWarning = postSecondStartEvents.find( + (event) => + event.type === "runtime.warning" && + event.payload.message.includes("sdk-turn-unmapped-after-complete-second"), + ); + + NodeAssert.equal(runtimeWarning, undefined); + NodeAssert.equal(staleRunningState, undefined); + + 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); + NodeAssert.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(); + } + NodeAssert.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", + ); + NodeAssert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(firstTurn.turnId), + ).length, + 1, + ); + NodeAssert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(secondTurn.turnId), + ).length, + 0, + ); + const latestEventAfterFirstIdle = runtimeEvents.at(-1); + NodeAssert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); + NodeAssert.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); + emit({ + id: "evt-copilot-queued-second-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } 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), + ); + NodeAssert.equal(secondCompletions.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not let stale sdk turn_start steal the next queued turn id", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stale-turn-start-does-not-steal-queue"); + + 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); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + + emit({ + id: "evt-copilot-stale-steal-first-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stale-steal-stale-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-stale", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + + emit({ + id: "evt-copilot-stale-steal-first-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stale-steal-first-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stale-steal-second-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stale-steal-second-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stale-steal-second-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(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const staleWarning = runtimeEvents.find( + (event) => + event.type === "runtime.warning" && event.payload.message.includes("sdk-turn-stale"), + ); + NodeAssert.equal(staleWarning, undefined); + + const firstCompletion = runtimeEvents.find( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + const secondCompletion = runtimeEvents.find( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + NodeAssert.equal(firstCompletion?.type, "turn.completed"); + NodeAssert.equal(secondCompletion?.type, "turn.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not let timestamped sdk replay consume a freshly queued turn", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-timestamped-replay-does-not-steal-queue"); + + 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); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const replayTimestamp = "1900-01-01T00:00:00.000Z"; + + const turn = yield* adapter.sendTurn({ + threadId, + input: "fresh prompt", + attachments: [], + }); + const liveTimestamp = yield* nowIso; + + for (const sdkTurnId of ["1", "2", "3"]) { + emit({ + id: `evt-copilot-replay-turn-${sdkTurnId}`, + timestamp: replayTimestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: sdkTurnId, + }, + } as SessionEvent); + } + emit({ + id: "evt-copilot-replay-live-start", + timestamp: liveTimestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-live-after-replay", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-replay-live-end", + timestamp: liveTimestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-live-after-replay", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-replay-live-idle", + timestamp: liveTimestamp, + 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(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const replayWarnings = runtimeEvents.filter( + (event) => + event.type === "runtime.warning" && event.payload.message.includes("Copilot turn start"), + ); + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(replayWarnings.length, 0); + NodeAssert.equal(completions.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "maps the next turn correctly after idle-only completion clears stale sdk turn state", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-idle-only-next-turn-mapping"); + + 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); + NodeAssert.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-idle-only-first-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-idle-only-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-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(); + } + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-idle-only-second-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-idle-only-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-second-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-idle-only-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-second-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(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const firstTurnCompletions = runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + const secondTurnCompletions = runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + NodeAssert.equal(firstTurnCompletions.length, 1); + NodeAssert.equal(secondTurnCompletions.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); + NodeAssert.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); + + NodeAssert.equal(runtimeMock.state.nativeWriteCalls > 0, true); + NodeAssert.equal(disconnectsBeforeDrain, 0); + NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); + NodeAssert.equal(runtimeMock.state.stopCalls, 1); + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(String(completed.turnId), String(turn.turnId)); + NodeAssert.equal(completed.payload.state, "completed"); + } + }), + ); + + it.effect("resolves open permission requests when stopping a session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-resolves-permission-request"); + + 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); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); + const permissionRequest: PermissionRequest = { + kind: "shell", + toolCallId: "tool-stop-open-permission", + fullCommandText: "git status", + intention: "Check repository status", + commands: [{ identifier: "git", readOnly: true }], + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: true, + }; + const requestId = "permission-stop-open"; + void config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-stop-open-permission", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + }, + } 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, + ); + } + NodeAssert.equal(opened?.type, "request.opened"); + + yield* adapter.stopSession(threadId); + let resolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && resolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + resolved = runtimeEvents.find( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(resolved?.type, "request.resolved"); + if (resolved?.type === "request.resolved") { + NodeAssert.equal(resolved.payload.decision, "reject"); + NodeAssert.deepStrictEqual(resolved.payload.resolution, { kind: "reject" }); + } + }), + ); + + 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); + + NodeAssert.equal(result._tag, "Failure"); + const aborted = runtimeEvents.find((event) => event.type === "turn.aborted"); + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + + NodeAssert.equal(aborted?.type, "turn.aborted"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (aborted?.type === "turn.aborted" && completed?.type === "turn.completed") { + NodeAssert.equal(String(completed.turnId), String(aborted.turnId)); + NodeAssert.equal(completed.payload.state, "failed"); + NodeAssert.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..71c9717c115 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,3458 @@ +import * as NodeCrypto from "node:crypto"; + +import type { + CopilotClient, + CopilotSession, + ContextTier, + MessageOptions, + PermissionRequest, + PermissionRequestResult, + SessionConfig, + SessionEvent, +} from "@github/copilot-sdk"; +import { + type CanonicalRequestType, + EventId, + type CopilotSettings, + type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + type ThreadTokenUsageSnapshot, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +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 { + classifyCopilotToolItemType, + isReadOnlyCopilotToolName, +} from "./CopilotToolClassification.ts"; +import { + commandLooksLikeCopilotPatchEdit, + extractCopilotApplyPatchEdit, + hasCopilotApplyPatchEdit, + hasPatchHeaderShape, + hasUnifiedDiffShape, + stripCopilotShellCompletionControlLines, +} from "./CopilotPatchDetection.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_RESUME_SCHEMA_VERSION = 1 as const; +const SDK_TURN_REPLAY_THRESHOLD_MS = 1_000; +const TURN_END_IDLE_FALLBACK_DELAY_MS = 10_000; + +type CopilotMode = "interactive" | "plan" | "autopilot"; +type CopilotReasoningEffort = NonNullable; +type CopilotContextTier = ContextTier; +type CopilotUserInputRequest = Parameters>[0]; +type CopilotUserInputResponse = Awaited< + ReturnType> +>; +type SessionPermissionRequestedEvent = Extract; +type SessionStartedEvent = Extract; +type SessionResumedEvent = 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 id?: string; + readonly type?: string; + readonly description: string; + readonly status: CopilotTaskStatus; + readonly agentType?: string; + readonly command?: string; + readonly prompt?: string; + readonly latestResponse?: string; + readonly result?: string; + readonly error?: string; +}; +type CopilotTaskList = { + readonly tasks: ReadonlyArray; +}; +type CopilotBackgroundTasksRpc = { + readonly backgroundTasks?: { + readonly list?: () => Promise; + }; +}; + +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +interface CopilotTaskState { + description: string; + status: CopilotTaskStatus; +} + +export interface CopilotAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly baseDirectory?: string; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly turnEndIdleFallbackDelayMs?: number; +} + +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: CanonicalRequestType; + 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 turnQueuedAtMsByTurnId: Map; + 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 emittedTurnDiffByTurnId: Map; + readonly copilotTasks: Map; + readonly turnIdsWithAssistantText: Set; + readonly startedItemIds: Set; + readonly turnEndEventsByTurnId: Map; + readonly turnEndFallbackTimers: Map>; + activeTurnId: TurnId | undefined; + activeSdkTurnId: string | undefined; + activeSdkTurnKey: 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(NodeCrypto.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 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* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + 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): CanonicalRequestType { + switch (request.kind) { + case "shell": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "write": + return "file_change_approval"; + default: + return "dynamic_tool_call"; + } +} + +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 { kind: "approve-for-session" }; + 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 isPermissionCompletionApproved(result: PermissionRequestResult): boolean { + const kind = result.kind as string; + return ( + kind === "approved" || + kind.startsWith("approved-") || + kind === "approve-once" || + kind === "approve-for-session" || + kind === "approve-for-location" || + kind === "approve-permanently" + ); +} + +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 classifyCopilotToolItemType({ + 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 completedToolDiffText( + toolMeta: ToolMeta | undefined, + detail: string | undefined, +): string | undefined { + const normalized = trimOrUndefined(detail); + const applyPatchDiff = + extractCopilotApplyPatchEdit(normalized) ?? extractCopilotApplyPatchEdit(toolMeta?.command); + if (isApplyPatchTool(toolMeta?.toolName)) { + return applyPatchDiff; + } + if (toolMeta?.itemType === "file_change" && applyPatchDiff) { + return applyPatchDiff; + } + if (!normalized) { + return undefined; + } + if (toolMeta?.itemType !== "command_execution" && toolMeta?.itemType !== "file_change") { + return undefined; + } + const diffCandidate = stripCopilotShellCompletionControlLines(normalized); + if (!hasUnifiedDiffShape(diffCandidate)) { + return undefined; + } + return parseTurnDiffFilesFromUnifiedDiff(diffCandidate).length > 0 ? diffCandidate : undefined; +} + +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 completedCopilotTaskStatus( + status: CopilotTaskStatus, +): "completed" | "failed" | "stopped" | undefined { + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "cancelled": + return "stopped"; + case "running": + case "idle": + return undefined; + } +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function copilotTaskId(task: CopilotTaskInfo): string | undefined { + return readString(task.id); +} + +function copilotTaskType(task: CopilotTaskInfo): string | undefined { + return readString(task.type) ?? readString(task.agentType); +} + +function copilotTaskDescription(task: CopilotTaskInfo): string { + return ( + readString(task.description) ?? + readString(task.command) ?? + readString(task.prompt) ?? + readString(task.latestResponse) ?? + "Task" + ); +} + +function copilotTaskProgressSummary(status: CopilotTaskStatus): string { + return status === "idle" ? "Task idle" : "Task running"; +} + +function copilotTaskCompletionSummary(task: CopilotTaskInfo): string | undefined { + return ( + readString(task.error) ?? + readString(task.result) ?? + readString(task.latestResponse) ?? + copilotTaskDescription(task) + ); +} + +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 = copilotTaskDescription(task); + return { + step: `${description}${copilotTaskStatusSuffix(task.status)}`, + status: normalizeCopilotTaskStatus(task.status), + }; + }); +} + +function isTodoTool(toolName: string): boolean { + const normalized = toolName.toLowerCase().replace(/[^a-z0-9]+/g, ""); + return normalized.includes("todo"); +} + +function normalizeTodoStatus(value: unknown): PlanStep["status"] { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "completed" || normalized === "done") { + return "completed"; + } + if ( + normalized === "in_progress" || + normalized === "inprogress" || + normalized === "running" || + normalized === "active" + ) { + return "inProgress"; + } + return "pending"; +} + +function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | undefined { + const todos = input.todos; + if (!Array.isArray(todos) || todos.length === 0) { + return undefined; + } + const steps = todos.flatMap((todo): Array => { + if (!isStringRecord(todo)) { + return []; + } + const step = readString(todo.content) ?? readString(todo.title) ?? readString(todo.task); + return [ + { + step: step ?? "Task", + status: normalizeTodoStatus(todo.status), + }, + ]; + }); + return steps.length > 0 ? steps : undefined; +} + +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 { + if (toolMeta?.itemType === "command_execution") { + const command = trimOrUndefined(toolMeta.command); + return command ? `Ran command: ${truncateSingleLine(command, 96)}` : "Ran command"; + } + if (toolMeta?.itemType === "file_change") { + return isApplyPatchTool(toolMeta.toolName) || commandLooksLikeCopilotPatchEdit(toolMeta.command) + ? "Applied patch" + : (toolMeta.toolName ?? "Updated files"); + } + return toolMeta?.toolName ?? "tool"; +} + +function truncateSingleLine(value: string, max = 120): string { + const singleLine = value.replace(/\r\n/g, "\n").replace(/\n+/g, " ").trim(); + if (singleLine.length <= max) { + return singleLine; + } + return `${singleLine.slice(0, Math.max(1, max - 3)).trimEnd()}...`; +} + +function normalizedToolCompletionDetail( + toolMeta: ToolMeta | undefined, + detail: string | undefined, +): string | undefined { + const normalized = trimOrUndefined(detail); + if (!normalized) { + return undefined; + } + if (toolMeta?.itemType === "command_execution") { + const withoutControlLines = stripCopilotShellCompletionControlLines(normalized); + return trimOrUndefined(withoutControlLines); + } + return normalized; +} + +function toolLifecycleDataKind(toolMeta: ToolMeta | undefined): "edit" | "read" | undefined { + if (!toolMeta) { + return undefined; + } + if (toolMeta.itemType === "file_change") { + return "edit"; + } + if (isReadOnlyCopilotToolName(toolMeta.toolName)) { + return "read"; + } + return undefined; +} + +function toolLifecycleData(input: { + readonly toolCallId: string; + readonly toolMeta: ToolMeta | undefined; + readonly arguments?: Record | undefined; + readonly result?: unknown; + readonly error?: unknown; + readonly toolTelemetry?: unknown; +}): Record { + const argumentsData: Record = input.arguments ? { ...input.arguments } : {}; + const kind = toolLifecycleDataKind(input.toolMeta); + if (input.toolMeta?.itemType !== "command_execution") { + delete argumentsData.command; + delete argumentsData.cmd; + delete argumentsData.fullCommandText; + delete argumentsData.commandText; + } + return { + ...argumentsData, + ...(kind ? { kind } : {}), + toolCallId: input.toolCallId, + ...(input.toolMeta?.toolName ? { toolName: input.toolMeta.toolName } : {}), + ...(input.toolMeta?.itemType === "command_execution" && 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 preferredAnswerTrimmed = preferredAnswer.trim(); + if (!binding.allowFreeform) { + const matchingChoice = binding.choices.find( + (choice) => choice.trim() === preferredAnswerTrimmed, + ); + return { + answer: matchingChoice ?? binding.choices[0] ?? preferredAnswer, + wasFreeform: false, + }; + } + const wasFreeform = + normalizedChoices.size === 0 ? true : !normalizedChoices.has(preferredAnswerTrimmed); + return { + answer: preferredAnswer, + wasFreeform, + }; +} + +function answersFromCompletedUserInput( + data: SessionUserInputCompletedEvent["data"], +): ProviderUserInputAnswers { + return { + answer: data.answer ?? "", + }; +} + +function settlePendingPermissionHandlers( + context: CopilotSessionContext, + onBindingSettled?: (binding: PendingPermissionBinding) => Effect.Effect, +): 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); + if (onBindingSettled) { + yield* onBindingSettled(binding).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 epochMsFromIso(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function isSdkEventBeforeQueuedTurn( + context: CopilotSessionContext, + turnId: TurnId, + timestamp: string | undefined, +): boolean { + const eventAtMs = epochMsFromIso(timestamp); + const turnQueuedAtMs = context.turnQueuedAtMsByTurnId.get(turnId); + return ( + eventAtMs !== undefined && + turnQueuedAtMs !== undefined && + eventAtMs + SDK_TURN_REPLAY_THRESHOLD_MS < turnQueuedAtMs + ); +} + +function sdkTurnMappingKey(agentId: string | undefined, sdkTurnId: string): string { + return agentId ? `${agentId}:${sdkTurnId}` : sdkTurnId; +} + +function removeQueuedTurn(context: CopilotSessionContext, turnId: TurnId): void { + const queueIndex = context.queuedTurnIds.indexOf(turnId); + if (queueIndex >= 0) { + context.queuedTurnIds.splice(queueIndex, 1); + } +} + +function clearSdkTurnMappingsForTurn(context: CopilotSessionContext, turnId: TurnId): void { + context.turnQueuedAtMsByTurnId.delete(turnId); + for (const [sdkTurnKey, mappedTurnId] of context.sdkTurnIdsToTurnIds.entries()) { + if (mappedTurnId === turnId) { + context.sdkTurnIdsToTurnIds.delete(sdkTurnKey); + if (context.activeSdkTurnKey === sdkTurnKey) { + context.activeSdkTurnId = undefined; + context.activeSdkTurnKey = undefined; + } + } + } +} + +function resolveTurnIdForSdkTurn( + context: CopilotSessionContext, + sdkTurnId: string, + input?: { + readonly timestamp?: string | undefined; + readonly agentId?: string | undefined; + }, +): TurnId | undefined { + const sdkTurnKey = sdkTurnMappingKey(input?.agentId, sdkTurnId); + const existing = context.sdkTurnIdsToTurnIds.get(sdkTurnKey); + if (existing) { + return existing; + } + + const activeTurnId = context.activeTurnId; + if (activeTurnId && !isSdkEventBeforeQueuedTurn(context, activeTurnId, input?.timestamp)) { + removeQueuedTurn(context, activeTurnId); + context.sdkTurnIdsToTurnIds.set(sdkTurnKey, activeTurnId); + context.activeSdkTurnId = sdkTurnId; + context.activeSdkTurnKey = sdkTurnKey; + updateProviderSession(context, { + status: "running", + activeTurnId, + }); + return activeTurnId; + } + + // When no T3 turn is active, a new SDK turn can start the next queued app turn. + const nextTurnId = context.queuedTurnIds[0]; + if (!nextTurnId) { + return undefined; + } + if (isSdkEventBeforeQueuedTurn(context, nextTurnId, input?.timestamp)) { + return undefined; + } + context.queuedTurnIds.shift(); + context.sdkTurnIdsToTurnIds.set(sdkTurnKey, nextTurnId); + ensureTurnSnapshot(context, nextTurnId); + context.activeSdkTurnId = sdkTurnId; + context.activeSdkTurnKey = sdkTurnKey; + 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 sdkEventTimestamp?: string | undefined; + readonly agentId?: string | undefined; + readonly parentProviderItemId?: string | undefined; + readonly allowActiveFallback?: boolean | 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, { + timestamp: input.sdkEventTimestamp, + agentId: input.agentId, + }); + } + if (input?.allowActiveFallback === false) { + return undefined; + } + if (context.activeSdkTurnKey) { + return context.sdkTurnIdsToTurnIds.get(context.activeSdkTurnKey) ?? context.activeTurnId; + } + return context.activeTurnId; +} + +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 runFork = Effect.runForkWith(runtimeContext); + const turnEndIdleFallbackDelayMs = Math.max( + 0, + options?.turnEndIdleFallbackDelayMs ?? TURN_END_IDLE_FALLBACK_DELAY_MS, + ); + + 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) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: detailFromCause(cause, "Failed to start Copilot client."), + cause, + }), + }), + stopClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.stop(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: 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) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: 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) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: 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, + }, + }, + }); + }); + }; + + function cancelTurnEndFallback(context: CopilotSessionContext, turnId?: TurnId): void { + if (turnId === undefined) { + for (const fiber of context.turnEndFallbackTimers.values()) { + void runWithContext(Fiber.interrupt(fiber).pipe(Effect.ignore)); + } + context.turnEndFallbackTimers.clear(); + return; + } + + const fiber = context.turnEndFallbackTimers.get(turnId); + if (fiber !== undefined) { + void runWithContext(Fiber.interrupt(fiber).pipe(Effect.ignore)); + context.turnEndFallbackTimers.delete(turnId); + } + } + + const emitTurnCompleted = async ( + context: CopilotSessionContext, + turnId: TurnId, + status: ProviderRuntimeTurnStatus, + input?: { + readonly stopReason?: string | null | undefined; + readonly errorMessage?: string | undefined; + readonly raw?: SessionEvent | undefined; + }, + ) => { + cancelTurnEndFallback(context, turnId); + // Copilot can report duplicate idle/error signals around the same user 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.turnEndEventsByTurnId.delete(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + clearSdkTurnMappingsForTurn(context, 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 shouldCompleteOnAssistantTurnEnd = ( + context: CopilotSessionContext, + turnId: TurnId, + ): boolean => + context.activeTurnId === turnId && + (context.turnIdsWithAssistantText.has(turnId) || + context.pendingTaskCompletionTextByTurnId.has(turnId)); + + const completePendingActiveTurnEnd = async (context: CopilotSessionContext) => { + const turnId = context.activeTurnId; + if ( + !turnId || + context.stopped || + context.queuedTurnIds.length === 0 || + !context.turnEndEventsByTurnId.has(turnId) + ) { + return; + } + const raw = context.turnEndEventsByTurnId.get(turnId)!; + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, raw); + await emitTurnCompleted(context, turnId, "completed", { + raw, + stopReason: null, + }); + }; + + const scheduleTurnEndFallback = ( + context: CopilotSessionContext, + turnId: TurnId, + raw: SessionEvent, + ): void => { + cancelTurnEndFallback(context, turnId); + const fiber = runFork( + Effect.sleep(`${turnEndIdleFallbackDelayMs} millis`).pipe( + Effect.andThen( + Effect.promise(async () => { + context.turnEndFallbackTimers.delete(turnId); + context.eventChain = context.eventChain.then(async () => { + if (!shouldCompleteOnAssistantTurnEnd(context, turnId) || context.stopped) { + return; + } + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, raw); + await emitTurnCompleted(context, turnId, "completed", { + raw, + stopReason: null, + }); + }); + await context.eventChain; + }), + ), + ), + ); + context.turnEndFallbackTimers.set(turnId, fiber); + }; + + const emitTurnDiffUpdated = async (input: { + readonly context: CopilotSessionContext; + readonly turnId: TurnId; + readonly diffText: string; + readonly raw?: SessionEvent | undefined; + }) => { + const normalizedDiff = trimOrUndefined(input.diffText); + if (!normalizedDiff) { + return; + } + const hasParsedFiles = parseTurnDiffFilesFromUnifiedDiff(normalizedDiff).length > 0; + if ( + !hasParsedFiles && + !hasCopilotApplyPatchEdit(normalizedDiff) && + !hasUnifiedDiffShape(normalizedDiff) && + !hasPatchHeaderShape(normalizedDiff) + ) { + return; + } + const previousDiff = input.context.emittedTurnDiffByTurnId.get(input.turnId); + if (previousDiff === normalizedDiff) { + return; + } + input.context.emittedTurnDiffByTurnId.set(input.turnId, normalizedDiff); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + turnId: input.turnId, + raw: input.raw, + }), + type: "turn.diff.updated", + payload: { + unifiedDiff: normalizedDiff, + }, + }); + }; + + 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); + input.context.assistantItemIdByTurnId.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, + }); + context.assistantItemIdByTurnId.set(turnId, itemId); + context.turnIdsWithAssistantText.add(turnId); + }; + + const emitPermissionRequestOpened = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + data: SessionPermissionRequestedEvent["data"], + ): Effect.Effect => { + const detail = permissionDetail(data.permissionRequest); + return 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, + ...(detail ? { detail } : {}), + 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; + } + for (const task of taskList.tasks) { + const taskId = copilotTaskId(task); + if (!taskId) { + continue; + } + const description = copilotTaskDescription(task); + const taskType = copilotTaskType(task); + const previous = context.copilotTasks.get(taskId); + const runtimeTaskId = RuntimeTaskId.make(taskId); + if (!previous) { + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "task.started", + payload: { + taskId: runtimeTaskId, + description, + ...(taskType ? { taskType } : {}), + }, + }); + } + if ( + (task.status === "running" || task.status === "idle") && + (!previous || previous.status !== task.status || previous.description !== description) + ) { + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "task.progress", + payload: { + taskId: runtimeTaskId, + description, + summary: copilotTaskProgressSummary(task.status), + }, + }); + } + const completedStatus = completedCopilotTaskStatus(task.status); + if (completedStatus && previous?.status !== task.status) { + const summary = copilotTaskCompletionSummary(task); + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "task.completed", + payload: { + taskId: runtimeTaskId, + status: completedStatus, + ...(summary ? { summary } : {}), + }, + }); + } + context.copilotTasks.set(taskId, { + description, + status: task.status, + }); + } + 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 emitSessionReadyEvents = async (input: { + readonly context: CopilotSessionContext; + readonly event: SessionStartedEvent | SessionResumedEvent; + readonly sessionId: string; + readonly message: string; + readonly stateReason: string; + }): Promise => { + const resumeCursor = toCopilotResumeCursor(input.sessionId); + updateProviderSession(input.context, { + status: "ready", + model: trimOrUndefined(input.event.data.selectedModel) ?? input.context.session.model, + ...(input.event.data.context?.cwd ? { cwd: input.event.data.context.cwd } : {}), + resumeCursor, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + raw: input.event, + }), + type: "session.started", + payload: { + message: input.message, + resume: resumeCursor, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + raw: input.event, + }), + type: "session.configured", + payload: { + config: { + model: input.event.data.selectedModel ?? null, + reasoningEffort: input.event.data.reasoningEffort ?? null, + cwd: input.event.data.context?.cwd ?? input.context.cwd, + }, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + raw: input.event, + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: input.stateReason, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + raw: input.event, + }), + type: "thread.started", + payload: { + providerThreadId: input.sessionId, + }, + }); + }; + + const handleSdkEvent = async ( + context: CopilotSessionContext, + event: SessionEvent, + ): Promise => { + switch (event.type) { + case "session.start": { + await emitSessionReadyEvents({ + context, + event, + sessionId: event.data.sessionId, + message: "Copilot session started.", + stateReason: "Copilot session ready", + }); + return; + } + case "session.resume": { + await emitSessionReadyEvents({ + context, + event, + sessionId: context.sdkSession.sessionId, + message: "Copilot session resumed.", + stateReason: "Copilot session resumed", + }); + 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": { + await completePendingActiveTurnEnd(context); + const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { + timestamp: event.timestamp, + agentId: event.agentId, + }); + if (!turnId) { + return; + } + 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, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + 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, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + 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, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + 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); + context.assistantItemIdByTurnId.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: event.data.turnId ?? context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + 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); + context.assistantItemIdByTurnId.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 sdkTurnKey = sdkTurnMappingKey(event.agentId, event.data.turnId); + const turnId = context.sdkTurnIdsToTurnIds.get(sdkTurnKey); + if (!turnId) { + return; + } + context.turnEndEventsByTurnId.set(turnId, event); + const shouldComplete = shouldCompleteOnAssistantTurnEnd(context, turnId); + if (context.activeSdkTurnKey === sdkTurnKey) { + context.activeSdkTurnId = undefined; + context.activeSdkTurnKey = undefined; + } + if (shouldComplete) { + scheduleTurnEndFallback(context, turnId, event); + } + return; + } + case "assistant.usage": { + const turnId = resolveTurnIdForEvent(context, { + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + }); + 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: event.data.turnId ?? context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + }); + if (!turnId) { + return; + } + const todoPlan = + isTodoTool(event.data.toolName) && isStringRecord(event.data.arguments) + ? extractPlanStepsFromTodoInput(event.data.arguments) + : undefined; + if (todoPlan && todoPlan.length > 0) { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "turn.plan.updated", + payload: { + explanation: "Copilot Todos", + plan: todoPlan, + }, + }); + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const itemType = toolItemType( + event.data.toolName, + event.data.mcpServerName, + event.data.arguments, + ); + const command = commandFromToolArguments(event.data.arguments); + const toolMeta: ToolMeta = { + toolName: event.data.toolName, + itemType, + ...(command ? { command } : {}), + }; + context.toolMetaById.set(event.data.toolCallId, { + ...toolMeta, + }); + context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); + if (isTaskCompleteTool(event.data.toolName)) { + return; + } + context.startedItemIds.add(itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.started", + payload: { + itemType, + status: "inProgress", + title: toolLifecycleTitle(toolMeta), + ...(toolMeta.itemType === "command_execution" && toolMeta.command + ? { detail: toolMeta.command } + : {}), + 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, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + allowActiveFallback: false, + }); + 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, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + allowActiveFallback: false, + }); + 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: event.data.turnId ?? context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + }); + 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 rawDetail = + trimOrUndefined(event.data.result?.detailedContent) ?? + trimOrUndefined(event.data.result?.content) ?? + trimOrUndefined(event.data.error?.message); + const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); + if (isTaskCompleteTool(toolMeta?.toolName)) { + if (event.data.success && detail) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + } + return; + } + 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, rawDetail) + : undefined; + if (diffText) { + await emitTurnDiffUpdated({ + context, + turnId, + diffText, + raw: event, + }); + } + 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); + 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); + if (binding.permissionRequest.kind === "write") { + const turnId = + binding.turnId ?? + resolveTurnIdForEvent(context, { + providerItemId: toolCallIdFromPermissionRequest(binding.permissionRequest), + sdkTurnId: context.activeSdkTurnId, + }); + if (turnId && isPermissionCompletionApproved(event.data.result)) { + const writeDiff = trimOrUndefined(binding.permissionRequest.diff); + if (writeDiff) { + await emitTurnDiffUpdated({ + context, + turnId, + diffText: writeDiff, + raw: event, + }); + } + } + } + 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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}', received '${input.provider}'.`, + }); + } + if (input.providerInstanceId !== undefined && input.providerInstanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, + }); + } + + if (sessions.has(input.threadId)) { + yield* stopSessionInternal(input.threadId); + } + + if (!settings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "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, + binaryPathBaseDirectory: serverConfig.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + ...(options?.environment ? { env: options.environment } : {}), + platform, + logLevel: "error", + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: 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: [], + turnQueuedAtMsByTurnId: new Map(), + 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(), + emittedTurnDiffByTurnId: new Map(), + copilotTasks: new Map(), + turnIdsWithAssistantText: new Set(), + startedItemIds: new Set(), + turnEndEventsByTurnId: new Map(), + turnEndFallbackTimers: new Map(), + activeTurnId: undefined, + activeSdkTurnId: undefined, + activeSdkTurnKey: 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) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: 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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Copilot turns require text input or at least one attachment.", + }); + } + + const turnId = TurnId.make(`copilot-turn-${NodeCrypto.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); + + const queuedAt = yield* DateTime.now; + ensureTurnSnapshot(context, turnId); + context.turnQueuedAtMsByTurnId.set(turnId, epochMsFromIso(DateTime.formatIso(queuedAt)) ?? 0); + context.queuedTurnIds.push(turnId); + yield* Effect.promise(async () => { + context.eventChain = context.eventChain.then(async () => { + await completePendingActiveTurnEnd(context); + }); + await context.eventChain; + }); + const shouldPromoteQueuedTurn = + context.activeTurnId === undefined && context.activeSdkTurnKey === 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", + }; + + const providerMessageId = 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); + } + context.turnQueuedAtMsByTurnId.delete(turnId); + 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) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: detailFromCause(cause, "Failed to emit Copilot turn completion."), + cause, + }), + }); + return yield* error; + }), + ), + ); + if (trimOrUndefined(providerMessageId)) { + context.turnIdByProviderItemId.set(providerMessageId, turnId); + } + + 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 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; + if ( + binding.permissionRequest.kind === "write" && + (decision === "accept" || decision === "acceptForSession") + ) { + const turnId = + binding.turnId ?? + resolveTurnIdForEvent(context, { + providerItemId: toolCallIdFromPermissionRequest(binding.permissionRequest), + sdkTurnId: context.activeSdkTurnId, + }); + const writeDiff = trimOrUndefined(binding.permissionRequest.diff); + if (turnId && writeDiff) { + yield* Effect.tryPromise({ + try: () => + emitTurnDiffUpdated({ + context, + turnId, + diffText: writeDiff, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: detailFromCause(cause, "Failed to emit Copilot write diff update."), + cause, + }), + }); + } + } + 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* emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: binding.requestId, + }), + type: "user-input.resolved", + payload: { + answers: { + answer: response.answer, + }, + }, + }); + context.pendingUserInputBindings.delete(requestId); + 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; + cancelTurnEndFallback(context); + yield* Effect.promise(() => context.eventChain); + yield* settlePendingPermissionHandlers(context, (binding) => + emitPermissionRequestResolved(context, binding, "reject", DENIED_PERMISSION_RESULT), + ); + 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* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + 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* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + 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/CopilotPatchDetection.ts b/apps/server/src/provider/Layers/CopilotPatchDetection.ts new file mode 100644 index 00000000000..b4ade96d2f0 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotPatchDetection.ts @@ -0,0 +1,69 @@ +function trimOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function commandLooksLikeCopilotPatchEdit(command: string | undefined): boolean { + if (!command) { + return false; + } + return /(?:^|\s)apply_patch(?:\s|$)/u.test(command) || command.includes("*** Begin Patch"); +} + +export function hasCopilotApplyPatchEdit(detail: string): boolean { + return extractCopilotApplyPatchEdit(detail) !== undefined; +} + +export function extractCopilotApplyPatchEdit(detail: string | undefined): string | undefined { + const normalized = trimOrUndefined(detail?.replace(/\r\n/g, "\n")); + if (!normalized) { + return undefined; + } + const beginIndex = normalized.indexOf("*** Begin Patch"); + if (beginIndex < 0) { + return undefined; + } + const lines = normalized.slice(beginIndex).split("\n"); + if (lines[0]?.trim() !== "*** Begin Patch") { + return undefined; + } + const patchLines: Array = []; + for (const line of lines) { + patchLines.push(line); + if (line.trim() === "*** End Patch") { + break; + } + } + if (patchLines.at(-1)?.trim() !== "*** End Patch") { + return undefined; + } + const patch = patchLines.join("\n").trim(); + return patch.includes("\n*** Update File: ") || + patch.includes("\n*** Add File: ") || + patch.includes("\n*** Delete File: ") || + patch.includes("\n*** Move to: ") + ? patch + : undefined; +} + +const SHELL_COMPLETION_CONTROL_LINE_PATTERN = /^]+ completed with exit code \d+>$/; + +export function stripCopilotShellCompletionControlLines(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(); +} + +export function hasUnifiedDiffShape(detail: string): boolean { + return ( + detail.includes("diff --git ") || + /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+\n@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(detail) + ); +} + +export function hasPatchHeaderShape(detail: string): boolean { + return /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+/u.test(detail); +} 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..22c153814d3 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -0,0 +1,125 @@ +import * as NodeAssert 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(), + }); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.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(), + }); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.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; + + NodeAssert.equal(firstSnapshot.checkedAt, "2026-06-08T12:00:00.000Z"); + NodeAssert.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..753b8a1b5a5 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,155 @@ +import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/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/CopilotToolClassification.test.ts b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts new file mode 100644 index 00000000000..697358857f5 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts @@ -0,0 +1,63 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "@effect/vitest"; + +import { + classifyCopilotToolItemType, + isReadOnlyCopilotToolName, +} from "./CopilotToolClassification.ts"; + +describe("CopilotToolClassification", () => { + it("classifies Copilot tool lifecycle items consistently", () => { + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "bash" }), "command_execution"); + NodeAssert.equal( + classifyCopilotToolItemType({ toolName: "Task_complete" }), + "collab_agent_tool_call", + ); + NodeAssert.equal( + classifyCopilotToolItemType({ + toolName: "update", + arguments: { + path: "README.md", + content: "new content", + }, + }), + "file_change", + ); + NodeAssert.equal( + classifyCopilotToolItemType({ + toolName: "run_in_terminal", + arguments: { + command: "apply_patch <<'PATCH'\n*** Begin Patch\n*** Update File: README.md\nPATCH", + }, + }), + "file_change", + ); + NodeAssert.equal( + classifyCopilotToolItemType({ + toolName: "execute", + arguments: { + filePath: "README.md", + newString: "updated", + }, + }), + "file_change", + ); + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "Read" }), "dynamic_tool_call"); + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "web_fetch" }), "web_search"); + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "screenshot" }), "image_view"); + NodeAssert.equal( + classifyCopilotToolItemType({ toolName: "call_tool", mcpServerName: "github" }), + "mcp_tool_call", + ); + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "TodoWrite" }), "dynamic_tool_call"); + }); + + it("detects read-only Copilot tools", () => { + NodeAssert.equal(isReadOnlyCopilotToolName("Read"), true); + NodeAssert.equal(isReadOnlyCopilotToolName("read_file"), true); + NodeAssert.equal(isReadOnlyCopilotToolName("grep"), true); + NodeAssert.equal(isReadOnlyCopilotToolName("edit_file"), false); + NodeAssert.equal(isReadOnlyCopilotToolName("bash"), false); + }); +}); diff --git a/apps/server/src/provider/Layers/CopilotToolClassification.ts b/apps/server/src/provider/Layers/CopilotToolClassification.ts new file mode 100644 index 00000000000..1a41d5e6733 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotToolClassification.ts @@ -0,0 +1,148 @@ +import type { ToolLifecycleItemType } from "@t3tools/contracts"; + +import { commandLooksLikeCopilotPatchEdit } from "./CopilotPatchDetection.ts"; + +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 toolCommandLooksLikeFileChange(arguments_: unknown): boolean { + if (!isRecord(arguments_)) { + return false; + } + const candidates = [ + arguments_.command, + arguments_.cmd, + arguments_.fullCommandText, + arguments_.commandText, + isRecord(arguments_.input) ? arguments_.input.command : undefined, + ]; + return candidates.some((candidate) => commandLooksLikeCopilotPatchEdit(trimmedString(candidate))); +} + +export function isReadOnlyCopilotToolName(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") + ); +} + +function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { + const normalized = toolName.toLowerCase(); + const isPlanningWriteTool = normalized.includes("todo") || normalized.includes("plan"); + if ( + (normalized.includes("write") && !isPlanningWriteTool) || + 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 classifyCopilotToolItemType(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 ( + toolNameImpliesFileChange(input.toolName, input.arguments) || + toolCommandLooksLikeFileChange(input.arguments) + ) { + return "file_change"; + } + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("exec") || + normalized.includes("command") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + 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"; +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..bdd366ad682 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1435,6 +1435,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te 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..75f71dc5a80 --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -0,0 +1,378 @@ +// @effect-diagnostics nodeBuiltinImport:off - Test creates a temporary executable fixture. +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it } from "@effect/vitest"; +import type { CopilotClientOptions } from "@github/copilot-sdk"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { + authSnapshotFromCopilotSdk, + buildCopilotClientOptions, + capabilitiesFromCopilotModel, + createCopilotClient, + formatCopilotProbeError, + modelsFromCopilotSdk, + normalizeCopilotRuntimeEnvironment, + resolveBundledCopilotCliPath, +} from "./copilotRuntime.ts"; + +function assertStdioConnection(connection: CopilotClientOptions["connection"]) { + NodeAssert.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"); + + NodeAssert.equal(env.PATH, "/custom/bin:/bin"); + }); + + it.effect("returns typed failures for invalid Copilot client configuration", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + createCopilotClient({ + settings: { + enabled: true, + binaryPath: "", + serverUrl: "http://[::1", + customModels: [], + }, + platform: "darwin", + }), + ); + + NodeAssert.equal(Exit.isFailure(exit), true); + if (Exit.isFailure(exit)) { + const failure = Cause.squash(exit.cause); + const error = failure as { + readonly _tag?: string; + readonly detail?: string; + readonly serverUrl?: string; + readonly cause?: unknown; + readonly message?: string; + }; + NodeAssert.equal(error._tag, "CopilotCliPathResolutionError"); + NodeAssert.equal(error.detail, "Failed to construct Copilot client."); + NodeAssert.equal(error.serverUrl, "http://[::1"); + NodeAssert.ok(error.cause instanceof Error); + NodeAssert.equal( + error.message, + "Copilot CLI path resolution failed (serverUrl=http://[::1): Failed to construct Copilot client.", + ); + } + }), + ); + + it.effect("formats Copilot probe failures from nested causes", () => + Effect.gen(function* () { + const error = yield* createCopilotClient({ + settings: { + enabled: true, + binaryPath: "", + serverUrl: "http://[::1", + customModels: [], + }, + platform: "darwin", + }).pipe(Effect.flip); + + const formatted = formatCopilotProbeError({ + cause: error, + settings: { + enabled: true, + binaryPath: "", + serverUrl: "http://[::1", + customModels: [], + }, + }); + + NodeAssert.equal(formatted.installed, true); + NodeAssert.notEqual(formatted.message, "Failed to construct Copilot client."); + }), + ); + + it("normalizes and deduplicates built-in Copilot SDK model slugs", () => { + const models = modelsFromCopilotSdk({ + models: [ + { + id: "4.1", + name: "", + capabilities: { + supports: { vision: false, reasoningEffort: false }, + limits: { max_prompt_tokens: 272_000, max_context_window_tokens: 400_000 }, + }, + billing: { + tokenPrices: { contextMax: 272_000 }, + }, + } as unknown as Parameters[0]["models"][number], + { + id: "gpt-4.1", + name: "GPT 4.1 duplicate", + capabilities: { + supports: { vision: false, reasoningEffort: false }, + limits: { max_prompt_tokens: 272_000, max_context_window_tokens: 400_000 }, + }, + billing: { + tokenPrices: { contextMax: 272_000 }, + }, + } as unknown as Parameters[0]["models"][number], + ], + customModels: ["gpt-4.1"], + }); + + NodeAssert.equal(models.length, 1); + const [model] = models; + NodeAssert.equal(model?.slug, "gpt-4.1"); + NodeAssert.equal(model?.isCustom, false); + }); + + 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", + }); + + NodeAssert.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, + }, + }, + }); + + NodeAssert.deepStrictEqual(capabilities.optionDescriptors, []); + }); + }); + + it("hydrates a missing POSIX SHELL for Copilot shell spawning", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + NodeAssert.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", + ); + + NodeAssert.equal(relativeShellEnv.SHELL, fallbackShell); + NodeAssert.equal(shellWithWhitespaceEnv.SHELL, fallbackShell); + }); + + it("preserves valid POSIX SHELL paths", () => { + const validShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; + NodeAssert.ok(validShell); + + const env = normalizeCopilotRuntimeEnvironment({ SHELL: validShell }, "darwin"); + + NodeAssert.equal(env.SHELL, validShell); + }); + + it("forces the Copilot POSIX shell spawn backend to avoid node-pty failures", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + NodeAssert.equal(env.COPILOT_FEATURE_FLAGS, "SHELL_SPAWN_BACKEND"); + NodeAssert.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", + ); + + NodeAssert.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"); + + NodeAssert.equal(env.SHELL, "bash"); + NodeAssert.equal(env.COPILOT_FEATURE_FLAGS, undefined); + NodeAssert.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); + NodeAssert.ok(connection.path?.includes("node_modules/.bin/copilot")); + NodeAssert.equal(options.workingDirectory, "/tmp/project"); + NodeAssert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); + NodeAssert.equal(options.logLevel, "error"); + NodeAssert.equal(options.mode, "copilot-cli"); + NodeAssert.equal(options.env?.COPILOT_CLI_PATH, undefined); + NodeAssert.equal(options.env?.GITHUB_TOKEN, "github-token"); + NodeAssert.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", + }); + + NodeAssert.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); + NodeAssert.equal(connection.path, configuredBinaryPath); + NodeAssert.equal(options.env?.COPILOT_CLI_PATH, undefined); + }), + ); + + it.effect("resolves configured relative binary paths from the binary path base directory", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "copilot-cli-base-")); + const workspaceDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "copilot-cli-cwd-")); + const binDir = NodePath.join(baseDir, "node_modules", ".bin"); + NodeFS.mkdirSync(binDir, { recursive: true }); + const binaryPath = NodePath.join(binDir, "copilot"); + NodeFS.writeFileSync(binaryPath, "#!/bin/sh\nexit 0\n"); + NodeFS.chmodSync(binaryPath, 0o755); + + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "./node_modules/.bin/copilot", + serverUrl: "", + customModels: [], + }, + cwd: workspaceDir, + binaryPathBaseDirectory: baseDir, + env: { PATH: "/usr/bin" }, + platform: "darwin", + }); + + const connection = assertStdioConnection(options.connection); + NodeAssert.equal(connection.path, binaryPath); + NodeAssert.equal(options.workingDirectory, workspaceDir); + NodeFS.rmSync(baseDir, { recursive: true, force: true }); + NodeFS.rmSync(workspaceDir, { recursive: true, force: true }); + }), + ); + }); + + 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", + }); + + NodeAssert.equal(snapshot.auth.status, "authenticated"); + NodeAssert.equal(snapshot.auth.type, "user"); + NodeAssert.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", + }); + + NodeAssert.equal(snapshot.auth.status, "authenticated"); + NodeAssert.equal(snapshot.auth.type, "gh-cli"); + NodeAssert.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..526ea9f6dc3 --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.ts @@ -0,0 +1,665 @@ +// @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 * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL 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, normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +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 Schema.TaggedErrorClass()( + "CopilotProbePromiseError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return this.cause instanceof Error ? this.cause.message : String(this.cause); + } +} + +class CopilotCliPathResolutionError extends Schema.TaggedErrorClass()( + "CopilotCliPathResolutionError", + { + detail: Schema.String, + binaryPath: Schema.optional(Schema.String), + serverUrl: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const context = [ + this.binaryPath ? `binaryPath=${this.binaryPath}` : undefined, + this.serverUrl ? `serverUrl=${this.serverUrl}` : undefined, + ] + .filter((part): part is string => part !== undefined) + .join(", "); + return `Copilot CLI path resolution failed${context ? ` (${context})` : ""}: ${this.detail}`; + } +} + +function copilotClientConfigurationError(input: { + readonly settings: CopilotSettings; + readonly detail: string; + readonly cause: unknown; +}): CopilotCliPathResolutionError { + return new CopilotCliPathResolutionError({ + detail: input.detail, + ...(trimOrUndefined(input.settings.binaryPath) + ? { binaryPath: trimOrUndefined(input.settings.binaryPath) } + : {}), + ...(trimOrUndefined(input.settings.serverUrl) + ? { serverUrl: trimOrUndefined(input.settings.serverUrl) } + : {}), + cause: input.cause, + }); +} + +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; + const fallbackMessages: string[] = []; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const tag = "_tag" in current && typeof current._tag === "string" ? current._tag : undefined; + const message = current instanceof Error ? current.message.trim() : ""; + const detail = + "detail" in current && typeof current.detail === "string" ? current.detail.trim() : ""; + const nestedCause = "cause" in current ? current.cause : undefined; + if (tag === "CopilotCliPathResolutionError" && nestedCause !== undefined) { + if (detail.length > 0) { + fallbackMessages.push(detail); + } + current = nestedCause; + continue; + } + if (message.length > 0 && !GENERIC_EFFECT_TRY_PROMISE_MESSAGES.has(message)) { + return message; + } + if (detail.length > 0 && !GENERIC_EFFECT_TRY_PROMISE_MESSAGES.has(detail)) { + return detail; + } + if (nestedCause === undefined) { + break; + } + current = nestedCause; + } + + if (typeof current === "string") { + return current.trim(); + } + + return fallbackMessages[0] ?? ""; +} + +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 binaryPathBaseDirectory?: string; + readonly baseDirectory?: string; + readonly env?: Record; + readonly platform: NodeJS.Platform; + readonly logLevel?: CopilotClientOptions["logLevel"]; + readonly onListModels?: CopilotClientOptions["onListModels"]; +}): Effect.fn.Return { + const options = yield* buildCopilotClientOptions(input); + return yield* Effect.try({ + try: () => new CopilotClient(options), + catch: (cause) => + copilotClientConfigurationError({ + settings: input.settings, + detail: "Failed to construct Copilot client.", + cause, + }), + }); +}); + +function isExecutableFile(path: string): boolean { + try { + if (!NodeFS.statSync(path).isFile()) { + return false; + } + + NodeFS.accessSync(path, NodeFS.constants.X_OK); + return true; + } catch { + return false; + } +} + +function isValidPosixShellPath(path: string | undefined): path is string { + return !!path && !/\s/.test(path) && NodePath.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; + readonly cwd?: string | undefined; + }, +) => + resolveCommandPath(resolveCommandRelativeToCwd(command, input), { env: input.env }).pipe( + Effect.provideService(HostProcessPlatform, input.platform), + Effect.provide(NodeServices.layer), + ); + +function resolveCommandRelativeToCwd( + command: string, + input: { readonly cwd?: string | undefined; readonly platform: NodeJS.Platform }, +): string { + if (!input.cwd || (!command.includes("/") && !command.includes("\\"))) { + return command; + } + const path = input.platform === "win32" ? NodePath.win32 : NodePath.posix; + return path.isAbsolute(command) ? command : path.resolve(input.cwd, command); +} + +const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCliPath")( + function* (input: { + readonly settings: CopilotSettings; + readonly binaryPathBaseDirectory?: string | undefined; + 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, + ...(input.binaryPathBaseDirectory ? { cwd: input.binaryPathBaseDirectory } : {}), + }).pipe( + Effect.catchTag("CommandResolutionError", () => + Effect.fail( + new CopilotCliPathResolutionError({ + detail: "The configured Copilot binary could not be found.", + binaryPath: 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 = NodePath.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 = NodePath.dirname(NodeURL.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 = NodePath.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 binaryPathBaseDirectory?: 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, + ...((input.binaryPathBaseDirectory ?? input.cwd) + ? { binaryPathBaseDirectory: input.binaryPathBaseDirectory ?? input.cwd } + : {}), + env, + platform: input.platform, + }); + const bundledCliPath = + !cliUrl && !configuredCliPath + ? yield* resolveBundledCopilotCliPath({ + ...(input.cwd ? { cwd: input.cwd } : {}), + env, + platform: input.platform, + }) + : undefined; + const cliPath = configuredCliPath ?? bundledCliPath; + const connection = cliUrl + ? yield* Effect.try({ + try: () => RuntimeConnection.forUri(cliUrl), + catch: (cause) => + copilotClientConfigurationError({ + settings: input.settings, + detail: "Invalid Copilot server URL.", + cause, + }), + }) + : cliPath + ? RuntimeConnection.forStdio({ path: cliPath }) + : undefined; + + return { + ...(connection ? { connection } : {}), + 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: ServerProviderModel[] = []; + const seenBuiltInSlugs = new Set(); + + for (const model of input.models) { + const rawSlug = model.id.trim(); + const slug = normalizeModelSlug(rawSlug, PROVIDER) ?? rawSlug; + if (seenBuiltInSlugs.has(slug)) { + continue; + } + seenBuiltInSlugs.add(slug); + builtInModels.push({ + slug, + name: trimOrUndefined(model.name) ?? slug, + isCustom: false, + capabilities: capabilitiesFromCopilotModel(model), + }); + } + + 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/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..1f99ec75ef7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; +import { type BranchNameGenerationInput } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -23,10 +24,11 @@ import { buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { + makeBranchNameGenerationResult, + makeCommitMessageGenerationResult, + makePrContentGenerationResult, + makeThreadTitleGenerationResult, normalizeCliError, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -117,7 +119,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generatePrContent" | "generateBranchName" | "generateThreadTitle", - attachments: TextGeneration.BranchNameGenerationInput["attachments"], + attachments: BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { return { imagePaths: [] }; @@ -312,13 +314,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func modelSelection: input.modelSelection, }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: sanitizeFeatureBranchName, + }); }); const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = @@ -339,10 +339,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func modelSelection: input.modelSelection, }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; + return makePrContentGenerationResult(generated); }); const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = @@ -365,9 +362,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func modelSelection: input.modelSelection, }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; + return makeBranchNameGenerationResult(generated, sanitizeBranchFragment); }); const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = @@ -390,11 +385,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func modelSelection: input.modelSelection, }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies TextGeneration.ThreadTitleGenerationResult; + return makeThreadTitleGenerationResult(generated); }); - return { generateCommitMessage, generatePrContent, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts new file mode 100644 index 00000000000..31d713946b1 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -0,0 +1,197 @@ +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; + }; + }>, + sessionConfigs: [] as Array, + sessions: [] as Array<{ + readonly disconnect: ReturnType; + readonly sendAndWait: ReturnType; + }>, + }; + + return { + state, + reset() { + state.createdClients = []; + state.sessionConfigs = []; + 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 (config: unknown) => { + runtimeMock.state.sessionConfigs.push(config); + 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"); + }), + ); + + it.effect("passes model options to Copilot text generation sessions", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1", [ + { id: "reasoningEffort", value: "high" }, + { id: "contextTier", value: "long_context" }, + ]); + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-options", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + expect(runtimeMock.state.sessionConfigs).toMatchObject([ + { + model: "gpt-4.1", + reasoningEffort: "high", + contextTier: "long_context", + }, + ]); + }), + ); + + it.effect("generates thread titles without starting a Copilot SDK session", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + + const title = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Investigate Copilot thread startup errors after reconnecting.", + modelSelection, + }); + + expect(title.title).toBe("Investigate Copilot thread startup errors after..."); + expect(runtimeMock.state.createdClients).toHaveLength(0); + expect(runtimeMock.state.sessions).toHaveLength(0); + }), + ); +}); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts new file mode 100644 index 00000000000..aa9c22cb0c0 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -0,0 +1,496 @@ +import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +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, +} from "./TextGenerationPrompts.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + makeBranchNameGenerationResult, + makeCommitMessageGenerationResult, + makePrContentGenerationResult, + makeThreadTitleGenerationResult, + 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; +type CopilotContextTier = 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 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, + }); +} + +function copilotThreadTitleFallback(input: { + readonly message: string; + readonly attachments?: ReadonlyArray | undefined; +}): TextGeneration.ThreadTitleGenerationResult { + const attachmentName = input.attachments?.[0]?.name; + const title = + input.message.trim() || (attachmentName ? `Image: ${attachmentName}` : "New thread"); + return makeThreadTitleGenerationResult({ title }); +} + +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 existingClient = yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const existing = sharedClients.get(clientKey); + if (existing) { + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return existing.client; + } + return undefined; + }), + ); + if (existingClient) { + return { clientKey, client: existingClient }; + } + + const newClient = yield* createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + binaryPathBaseDirectory: serverConfig.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to configure Copilot client."), + cause, + }), + ), + ); + const client = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore( + Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + const abort = () => { + void newClient.stop().catch(() => undefined); + reject(signal.reason ?? new Error("Copilot client startup interrupted.")); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); + newClient + .start() + .then(resolve, reject) + .finally(() => { + signal.removeEventListener("abort", abort); + }); + }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to start Copilot client."), + cause, + }), + }), + ); + + return yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const existing = sharedClients.get(clientKey); + if (existing) { + yield* Effect.tryPromise({ + try: () => newClient.stop(), + catch: () => undefined, + }).pipe(Effect.ignore); + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return existing.client; + } + + sharedClients.set(clientKey, { + client: newClient, + activeRequests: 1, + idleCloseFiber: null, + }); + return newClient; + }), + ); + }), + ); + 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; + const contextTier = getModelSelectionStringOptionValue( + input.modelSelection, + "contextTier", + ) as CopilotContextTier | 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 } : {}), + ...(contextTier ? { contextTier } : {}), + workingDirectory: input.cwd, + streaming: false, + availableTools: [], + enableConfigDiscovery: false, + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: 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) => + new TextGenerationError({ + operation: input.operation, + detail: 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: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CopilotTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: (branch) => sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + }); + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CopilotTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return makePrContentGenerationResult(generated); + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CopilotTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return makeBranchNameGenerationResult(generated, (branch) => + sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + ); + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( + input, + ) => Effect.succeed(copilotThreadTitleFallback(input)); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..65ed0755186 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,7 +7,13 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry 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/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index ad2911c20f7..563136032aa 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -1,6 +1,13 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import type { + BranchNameGenerationResult, + CommitMessageGenerationResult, + PrContentGenerationResult, + ThreadTitleGenerationResult, +} from "./TextGeneration.ts"; + const isTextGenerationError = Schema.is(TextGenerationError); /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ @@ -63,6 +70,53 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, 47).trimEnd()}...`; } +type BranchSanitizer = (raw: string) => string; + +export function makeCommitMessageGenerationResult(input: { + readonly generated: { + readonly subject: string; + readonly body: string; + readonly branch?: unknown; + }; + readonly includeBranch: boolean; + readonly sanitizeBranch: BranchSanitizer; +}): CommitMessageGenerationResult { + return { + subject: sanitizeCommitSubject(input.generated.subject), + body: input.generated.body.trim(), + ...(input.includeBranch && typeof input.generated.branch === "string" + ? { branch: input.sanitizeBranch(input.generated.branch) } + : {}), + }; +} + +export function makePrContentGenerationResult(input: { + readonly title: string; + readonly body: string; +}): PrContentGenerationResult { + return { + title: sanitizePrTitle(input.title), + body: input.body.trim(), + }; +} + +export function makeBranchNameGenerationResult( + input: { readonly branch: string }, + sanitizeBranch: BranchSanitizer, +): BranchNameGenerationResult { + return { + branch: sanitizeBranch(input.branch), + }; +} + +export function makeThreadTitleGenerationResult(input: { + readonly title: string; +}): ThreadTitleGenerationResult { + return { + title: sanitizeThreadTitle(input.title), + }; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index e5555cb0115..5dda241910d 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -72,7 +72,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { } return counts; }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { 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 067e71ef1bf..8cfb1c3ae9d 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -165,6 +165,30 @@ describe("getComposerProviderState", () => { ); }); + it("does not treat non-effort select descriptors as promptEffort", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("variant", [ + { id: "prod", label: "Prod", isDefault: true }, + { id: "test", label: "Test" }, + ]), + selectDescriptor("contextTier", [ + { id: "default", label: "Default", isDefault: true }, + { id: "long_context", label: "Long Context" }, + ]), + ]), + modelOptions: selections(["variant", "test"], ["contextTier", "long_context"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["variant", "test"], ["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 1349e2509b7..7a9f09ad410 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"]); + export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; @@ -58,7 +60,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 77c1813f110..7a3637eb239 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,7 +13,7 @@ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSet 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 ac2f7be81e8..65484eb595b 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; @@ -401,14 +456,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; @@ -579,11 +633,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..c6196f3efa5 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,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenAI, settingsSchema: CodexSettings, }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + icon: GithubCopilotIcon, + badgeLabel: "Early Access", + 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..88d30ce65a0 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -34,6 +34,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Claude"; case "codex": return "Codex"; + case "copilot": + return "GitHub Copilot"; case "cursor": return "Cursor"; case "opencode": @@ -47,52 +49,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 0f12e672f66..4a1b5ae13b8 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 5d5051f748e..69b0aa967b2 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/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..ee290701885 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -385,6 +385,7 @@ export function applyThreadDetailEvent( // checkpoint, but don't settle a turn its session is still running. const diffTurnStillRunning = thread.session?.status === "running" && + thread.session.activeTurnId !== null && thread.session.activeTurnId === event.payload.turnId; const latestTurn = !diffTurnStillRunning && diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..60e02cb19e0 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-5", [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", @@ -204,6 +210,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.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..6a38be777ce 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -142,6 +142,10 @@ describe("ServerSettingsPatch string normalization", () => { binaryPath: " /opt/homebrew/bin/codex ", homePath: " ~/.codex ", }, + copilot: { + binaryPath: " /opt/homebrew/bin/copilot ", + serverUrl: " http://127.0.0.1:4141 ", + }, }, providerInstances: { codex_personal: { @@ -157,6 +161,8 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); + expect(patch.providers?.copilot?.binaryPath).toBe("/opt/homebrew/bin/copilot"); + expect(patch.providers?.copilot?.serverUrl).toBe("http://127.0.0.1:4141"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..894606bf17b 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( @@ -395,6 +435,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({}))), @@ -472,6 +513,13 @@ const CodexSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CopilotSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + serverUrl: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -519,6 +567,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/pnpm-lock.yaml b/pnpm-lock.yaml index e54d6056d48..eed0c9aa4d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,6 +455,12 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + '@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 @@ -2685,6 +2691,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'} @@ -10079,6 +10141,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==} @@ -12479,6 +12545,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.27)': dependencies: hono: 4.12.27 @@ -20411,6 +20520,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 c7e33f6fc6f..fbb73d04989 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -166,6 +166,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(