From 6d96a516bdb7c0317af27adb7fdfa04a30a7394c Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:15:48 +0200 Subject: [PATCH 001/104] Add Copilot SDK support --- apps/server/package.json | 3 +- .../src/provider/Drivers/CopilotDriver.ts | 116 + .../src/provider/Layers/CopilotAdapter.ts | 2426 +++++++++++++++++ .../src/provider/Layers/CopilotProvider.ts | 147 + .../src/provider/Services/CopilotAdapter.ts | 10 + .../src/provider/Services/CopilotProvider.ts | 9 + apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/copilotRuntime.ts | 273 ++ .../textGeneration/CopilotTextGeneration.ts | 456 ++++ .../src/textGeneration/TextGeneration.ts | 8 +- .../components/chat/ModelPickerSidebar.tsx | 1 - .../src/components/chat/providerIconUtils.ts | 11 +- .../settings/AddProviderInstanceDialog.tsx | 7 +- .../components/settings/providerDriverMeta.ts | 17 +- apps/web/src/session-logic.ts | 1 + packages/contracts/src/model.ts | 7 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 49 + 18 files changed, 3534 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CopilotDriver.ts create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.ts create mode 100644 apps/server/src/provider/Layers/CopilotProvider.ts create mode 100644 apps/server/src/provider/Services/CopilotAdapter.ts create mode 100644 apps/server/src/provider/Services/CopilotProvider.ts create mode 100644 apps/server/src/provider/copilotRuntime.ts create mode 100644 apps/server/src/textGeneration/CopilotTextGeneration.ts diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..27a7fead350 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,7 +27,8 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-node": "0.9.4", + "@ff-labs/fff-node": "^0.9.4", + "@github/copilot-sdk": "^0.2.2", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts new file mode 100644 index 00000000000..8817865255e --- /dev/null +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -0,0 +1,116 @@ +import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, Path, Schema, Stream } from "effect"; + +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); + +export type CopilotDriverEnv = Path.Path | ProviderEventLoggers | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CopilotDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "GitHub Copilot", + supportsMultipleInstances: true, + }, + configSchema: CopilotSettings, + defaultConfig: (): CopilotSettings => Schema.decodeSync(CopilotSettings)({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies CopilotSettings; + 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", + }); + + const adapter = yield* makeCopilotAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => stampIdentity(makePendingCopilotProvider(settings)), + checkProvider: checkCopilotProviderStatus({ + settings: effectiveConfig, + cwd: serverConfig.cwd, + 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.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 00000000000..97ef9d9169b --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,2426 @@ +import type { + CopilotClient, + CopilotSession, + MessageOptions, + PermissionRequest, + PermissionRequestResult, + SessionConfig, + SessionEvent, +} from "@github/copilot-sdk"; +import { + EventId, + type CopilotSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + type ThreadTokenUsageSnapshot, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { Deferred, Effect, Layer, Path, Predicate, PubSub, Random, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { createCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_RESUME_SCHEMA_VERSION = 1 as const; + +type CopilotMode = "interactive" | "plan" | "autopilot"; +type CopilotReasoningEffort = NonNullable; +type CopilotUserInputRequest = Parameters>[0]; +type CopilotUserInputResponse = Awaited< + ReturnType> +>; +type SessionPermissionRequestedEvent = Extract; +type SessionUserInputRequestedEvent = Extract; +type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; + +interface CopilotAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface CopilotTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface PendingPermissionHandler { + readonly signature: string; + readonly deferred: Deferred.Deferred; +} + +interface PendingUserInputHandler { + readonly signature: string; + readonly deferred: Deferred.Deferred; +} + +interface PendingPermissionBinding { + readonly requestId: string; + readonly requestType: + | "command_execution_approval" + | "file_read_approval" + | "file_change_approval"; + readonly 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"; +} + +interface CopilotSessionContext { + readonly threadId: ThreadId; + readonly client: CopilotClient; + readonly sdkSession: CopilotSession; + session: ProviderSession; + readonly cwd: string; + readonly turns: Array; + readonly queuedTurnIds: Array; + readonly sdkTurnIdsToTurnIds: Map; + readonly completedTurnIds: Set; + readonly turnUsageByTurnId: Map; + readonly pendingPermissionHandlersBySignature: Map>; + readonly pendingPermissionEventsBySignature: Map< + string, + Array + >; + readonly pendingPermissionBindings: Map; + readonly pendingUserInputHandlersBySignature: Map>; + readonly pendingUserInputEventsBySignature: Map< + string, + Array + >; + readonly pendingUserInputBindings: Map; + readonly toolMetaById: Map; + readonly turnIdByProviderItemId: Map; + readonly emittedTextByItemId: Map; + readonly pendingTaskCompletionTextByTurnId: Map; + readonly turnIdsWithAssistantText: Set; + readonly startedItemIds: Set; + activeTurnId: TurnId | undefined; + activeSdkTurnId: string | undefined; + eventChain: Promise; + stopped: boolean; +} + +const APPROVED_PERMISSION_RESULT = { kind: "approved" } satisfies PermissionRequestResult; +const DENIED_PERMISSION_RESULT = { + kind: "denied-interactively-by-user", +} satisfies PermissionRequestResult; +const EMPTY_USER_INPUT_RESPONSE = { + answer: "", + wasFreeform: true, +} satisfies CopilotUserInputResponse; + +function nowIso(): string { + return new Date().toISOString(); +} + +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 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; +}) { + return { + eventId: EventId.make(Effect.runSync(Random.nextUUIDv4)), + 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) } : {}), + ...(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 processError( + threadId: ThreadId, + detail: string, + cause?: unknown, +): ProviderAdapterProcessError { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function validationError(operation: string, issue: string): ProviderAdapterValidationError { + return new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue, + }); +} + +function sessionClosedError(threadId: ThreadId): ProviderAdapterSessionClosedError { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }); +} + +function sessionNotFoundError(threadId: ThreadId): ProviderAdapterSessionNotFoundError { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); +} + +function detailFromCause(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; +} + +function requireSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): Effect.Effect< + CopilotSessionContext, + ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError +> { + return Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return yield* sessionNotFoundError(threadId); + } + if (context.stopped) { + return yield* sessionClosedError(threadId); + } + return context; + }); +} + +function requestedCopilotMode(input: { + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly interactionMode?: ProviderSendTurnInput["interactionMode"] | undefined; +}): CopilotMode { + if (input.interactionMode === "plan") { + return "plan"; + } + return input.runtimeMode === "approval-required" ? "interactive" : "autopilot"; +} + +function mapPermissionRequestType( + request: SessionPermissionRequest, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" { + switch (request.kind) { + case "read": + return "file_read_approval"; + case "write": + return "file_change_approval"; + default: + return "command_execution_approval"; + } +} + +function 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); + default: + return undefined; + } +} + +function permissionSignature(request: { + readonly kind: string; + readonly toolCallId?: string; + readonly [key: string]: unknown; +}): 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, + ]); + 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 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): ToolMeta["itemType"] { + const normalized = toolName.toLowerCase(); + if (mcpServerName) { + return "mcp_tool_call"; + } + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("exec") || + normalized.includes("command") + ) { + return "command_execution"; + } + if ( + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("replace") + ) { + return "file_change"; + } + if (normalized.includes("search") || normalized.includes("fetch") || normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("image") || normalized.includes("screenshot")) { + return "image_view"; + } + if ( + normalized.includes("subagent") || + normalized.includes("agent") || + normalized.includes("delegate") || + normalized.includes("task") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function isTaskCompleteTool(toolName: string | undefined): boolean { + return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "taskcomplete"; +} + +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 usageSnapshotFromAssistantUsage( + event: Extract, +): ThreadTokenUsageSnapshot { + const inputTokens = event.data.inputTokens ?? 0; + const cachedInputTokens = event.data.cacheReadTokens ?? 0; + const outputTokens = event.data.outputTokens ?? 0; + const usedTokens = inputTokens + cachedInputTokens + outputTokens; + return { + usedTokens, + lastUsedTokens: usedTokens, + ...(inputTokens > 0 ? { inputTokens, lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens > 0 + ? { cachedInputTokens, lastCachedInputTokens: cachedInputTokens } + : {}), + ...(outputTokens > 0 ? { outputTokens, lastOutputTokens: outputTokens } : {}), + ...(typeof event.data.duration === "number" && Number.isFinite(event.data.duration) + ? { durationMs: Math.max(0, Math.round(event.data.duration)) } + : {}), + }; +} + +function usageSnapshotFromUsageInfo( + event: Extract, +): ThreadTokenUsageSnapshot { + const currentTokens = Math.max(0, Math.round(event.data.currentTokens)); + return { + usedTokens: currentTokens, + lastUsedTokens: currentTokens, + ...(event.data.tokenLimit > 0 ? { maxTokens: Math.round(event.data.tokenLimit) } : {}), + ...(event.data.conversationTokens !== undefined + ? { + inputTokens: event.data.conversationTokens, + lastInputTokens: event.data.conversationTokens, + } + : {}), + }; +} + +function firstAnswerValue(answers: ProviderUserInputAnswers): string | undefined { + for (const value of Object.values(answers)) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + const first = value.find((entry) => typeof entry === "string" && entry.trim().length > 0); + if (typeof first === "string") { + return first.trim(); + } + } + } + return undefined; +} + +function answerFromUserInput( + binding: PendingUserInputBinding, + answers: ProviderUserInputAnswers, +): CopilotUserInputResponse { + const preferredAnswer = + firstAnswerValue(answers) ?? + (binding.choices.length > 0 ? binding.choices[0] : undefined) ?? + ""; + const normalizedChoices = new Set(binding.choices.map((choice) => choice.trim())); + const wasFreeform = + normalizedChoices.size === 0 ? true : !normalizedChoices.has(preferredAnswer.trim()); + return { + answer: preferredAnswer, + wasFreeform, + }; +} + +function settlePendingPermissionHandlers( + context: CopilotSessionContext, +): Effect.Effect { + return Effect.gen(function* () { + for (const handlers of context.pendingPermissionHandlersBySignature.values()) { + for (const handler of handlers) { + yield* Deferred.succeed(handler.deferred, DENIED_PERMISSION_RESULT).pipe(Effect.ignore); + } + } + context.pendingPermissionHandlersBySignature.clear(); + context.pendingPermissionEventsBySignature.clear(); + + for (const binding of context.pendingPermissionBindings.values()) { + yield* Deferred.succeed(binding.deferred, DENIED_PERMISSION_RESULT).pipe(Effect.ignore); + } + context.pendingPermissionBindings.clear(); + }); +} + +function settlePendingUserInputs(context: CopilotSessionContext): Effect.Effect { + return Effect.gen(function* () { + for (const handlers of context.pendingUserInputHandlersBySignature.values()) { + for (const handler of handlers) { + yield* Deferred.succeed(handler.deferred, EMPTY_USER_INPUT_RESPONSE).pipe(Effect.ignore); + } + } + context.pendingUserInputHandlersBySignature.clear(); + context.pendingUserInputEventsBySignature.clear(); + + for (const binding of context.pendingUserInputBindings.values()) { + yield* Deferred.succeed(binding.deferred, EMPTY_USER_INPUT_RESPONSE).pipe(Effect.ignore); + } + context.pendingUserInputBindings.clear(); + }); +} + +function latestTurnId(context: CopilotSessionContext): TurnId | undefined { + return context.turns.at(-1)?.id; +} + +function resolveTurnIdForSdkTurn(context: CopilotSessionContext, sdkTurnId: string): TurnId { + const existing = context.sdkTurnIdsToTurnIds.get(sdkTurnId); + if (existing) { + return existing; + } + const nextTurnId = + context.queuedTurnIds.shift() ?? + context.activeTurnId ?? + latestTurnId(context) ?? + TurnId.make(`copilot-turn-${Effect.runSync(Random.nextUUIDv4)}`); + context.sdkTurnIdsToTurnIds.set(sdkTurnId, nextTurnId); + ensureTurnSnapshot(context, nextTurnId); + context.activeSdkTurnId = sdkTurnId; + context.activeTurnId = nextTurnId; + updateProviderSession(context, { + status: "running", + activeTurnId: nextTurnId, + }); + return nextTurnId; +} + +function resolveTurnIdForEvent( + context: CopilotSessionContext, + input?: { + readonly providerItemId?: string | undefined; + readonly sdkTurnId?: string | undefined; + readonly parentProviderItemId?: string | undefined; + }, +): TurnId | undefined { + const parentTurnId = + input?.parentProviderItemId && context.turnIdByProviderItemId.get(input.parentProviderItemId); + if (parentTurnId) { + return parentTurnId; + } + const providerItemTurnId = + input?.providerItemId && context.turnIdByProviderItemId.get(input.providerItemId); + if (providerItemTurnId) { + return providerItemTurnId; + } + if (input?.sdkTurnId) { + return resolveTurnIdForSdkTurn(context, input.sdkTurnId); + } + if (context.activeSdkTurnId) { + return context.sdkTurnIdsToTurnIds.get(context.activeSdkTurnId) ?? context.activeTurnId; + } + return context.activeTurnId ?? latestTurnId(context); +} + +export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( + settings: CopilotSettings, + options?: CopilotAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); + const serverConfig = yield* ServerConfig; + const runtimeEventPubSub = yield* PubSub.unbounded(); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const sessions = new Map(); + const path = yield* Path.Path; + const runtimeContext = yield* Effect.context(); + const runWithContext = Effect.runPromiseWith(runtimeContext); + + const emit = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); + const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => + nativeEventLogger + ? runWithContext( + nativeEventLogger.write({ source: "copilot.sdk.event", payload: event }, threadId), + ) + : Promise.resolve(); + + const copilotSdk = { + startClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }), + stopClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.stop(), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to stop Copilot client."), + cause, + ), + }), + createSession: ( + threadId: ThreadId, + client: CopilotClient, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.createSession(config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to create Copilot session."), + cause, + ), + }), + resumeSession: ( + threadId: ThreadId, + client: CopilotClient, + sessionId: string, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.resumeSession(sessionId, config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to resume Copilot session."), + cause, + ), + }), + setMode: ( + context: CopilotSessionContext, + mode: CopilotMode, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.rpc.mode.set({ mode }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.mode.set", + detail: detailFromCause(cause, "Failed to update Copilot mode."), + cause, + }), + }), + readPlan: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => (await context.sdkSession.rpc.plan.read()).content ?? "", + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.plan.read", + detail: detailFromCause(cause, "Failed to read Copilot plan."), + cause, + }), + }), + setModel: ( + context: CopilotSessionContext, + model: string, + reasoningEffort?: CopilotReasoningEffort | undefined, + ): Effect.Effect => + Effect.tryPromise({ + try: () => + context.sdkSession.setModel(model, reasoningEffort ? { reasoningEffort } : {}), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.setModel", + detail: detailFromCause(cause, "Failed to update Copilot model."), + cause, + }), + }), + send: ( + context: CopilotSessionContext, + messageOptions: MessageOptions, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.send(messageOptions), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: detailFromCause(cause, "Failed to send Copilot turn."), + cause, + }), + }), + abort: (context: CopilotSessionContext): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: detailFromCause(cause, "Failed to abort Copilot turn."), + cause, + }), + }), + disconnect: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.disconnect(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.disconnect", + detail: detailFromCause(cause, "Failed to disconnect Copilot session."), + cause, + }), + }), + } as const; + + const enqueueSdkEvent = (context: CopilotSessionContext, event: SessionEvent) => { + context.eventChain = context.eventChain + .then(async () => { + await writeNativeAsync(context.threadId, event); + await handleSdkEvent(context, event); + }) + .catch(async (error) => { + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message.trim() + : "Copilot event handling failed."; + updateProviderSession(context, { + status: "error", + lastError: message, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: { + error, + sourceEventType: event.type, + }, + }, + }); + }); + }; + + const emitTurnCompleted = async ( + context: CopilotSessionContext, + turnId: TurnId, + status: ProviderRuntimeTurnStatus, + input?: { + readonly stopReason?: string | null | undefined; + readonly errorMessage?: string | undefined; + readonly raw?: SessionEvent | undefined; + }, + ) => { + if (context.completedTurnIds.has(turnId)) { + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + return; + } + context.completedTurnIds.add(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } + updateProviderSession(context, { + status: status === "failed" ? "error" : context.stopped ? "closed" : "ready", + ...(status === "failed" && input?.errorMessage ? { lastError: input.errorMessage } : {}), + activeTurnId: undefined, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: input?.raw, + }), + type: "turn.completed", + payload: { + state: status, + ...(input?.stopReason !== undefined ? { stopReason: input.stopReason } : {}), + ...(context.turnUsageByTurnId.has(turnId) + ? { usage: context.turnUsageByTurnId.get(turnId) } + : {}), + ...(input?.errorMessage ? { errorMessage: input.errorMessage } : {}), + }, + }); + }; + + const emitTextDelta = async (input: { + readonly context: CopilotSessionContext; + readonly turnId: TurnId; + readonly itemId: string; + readonly itemType: "assistant_message" | "reasoning"; + readonly streamKind: "assistant_text" | "reasoning_text"; + readonly nextText: string; + readonly raw?: SessionEvent | undefined; + }) => { + if (!input.context.startedItemIds.has(input.itemId)) { + input.context.startedItemIds.add(input.itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + turnId: input.turnId, + itemId: input.itemId, + raw: input.raw, + }), + type: "item.started", + payload: { + itemType: input.itemType, + status: "inProgress", + }, + }); + } + + const previousText = input.context.emittedTextByItemId.get(input.itemId); + const delta = deltaFromBufferedText(previousText, input.nextText); + input.context.emittedTextByItemId.set(input.itemId, input.nextText); + if (delta.length === 0) { + return; + } + if (input.itemType === "assistant_message" && input.streamKind === "assistant_text") { + input.context.turnIdsWithAssistantText.add(input.turnId); + } + 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 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, + }); + }; + + const emitPermissionRequestOpened = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + data: SessionPermissionRequestedEvent["data"], + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: pending.requestId, + raw: { + ...({ + id: pending.requestId, + timestamp: nowIso(), + parentId: null, + ephemeral: true, + type: "permission.requested", + data, + } satisfies SessionPermissionRequestedEvent), + }, + }), + type: "request.opened", + payload: { + requestType: pending.requestType, + ...(permissionDetail(data.permissionRequest) + ? { detail: permissionDetail(data.permissionRequest) } + : {}), + args: data.permissionRequest, + }, + }); + + const 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(); + context.pendingPermissionBindings.set(requestId, { + requestId, + requestType: mapPermissionRequestType(eventData.permissionRequest), + deferred: handler.deferred, + }); + if ( + context.session.runtimeMode === "approval-required" && + 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 onPermissionRequest = ( + context: CopilotSessionContext, + request: PermissionRequest, + ): Effect.Effect => + Effect.gen(function* () { + if (context.session.runtimeMode !== "approval-required") { + return APPROVED_PERMISSION_RESULT; + } + if (context.stopped) { + return DENIED_PERMISSION_RESULT; + } + + const signature = permissionSignature(request as PermissionRequest & { kind: string }); + 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, + }, + }, + }), + ), + ); + + const emitPlanSnapshot = ( + context: CopilotSessionContext, + raw: SessionEvent, + fallbackPlan?: string | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const turnId = context.activeTurnId ?? latestTurnId(context); + if (!turnId) { + return; + } + const plan = fallbackPlan + ? fallbackPlan.trim() + : (yield* copilotSdk.readPlan(context)).trim(); + if (plan.length === 0) { + return; + } + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.proposed.completed", + payload: { + planMarkdown: plan, + }, + }); + }); + + const handleSdkEvent = async ( + context: CopilotSessionContext, + event: SessionEvent, + ): Promise => { + switch (event.type) { + case "session.start": { + updateProviderSession(context, { + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(event.data.sessionId), + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.started", + payload: { + message: "Copilot session started.", + resume: toCopilotResumeCursor(event.data.sessionId), + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, + }, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: "Copilot session ready", + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.started", + payload: { + providerThreadId: event.data.sessionId, + }, + }); + return; + } + case "session.resume": { + updateProviderSession(context, { + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(context.sdkSession.sessionId), + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.started", + payload: { + message: "Copilot session resumed.", + resume: toCopilotResumeCursor(context.sdkSession.sessionId), + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, + }, + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: "Copilot session resumed", + }, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.started", + payload: { + providerThreadId: context.sdkSession.sessionId, + }, + }); + return; + } + case "session.error": { + const message = trimOrUndefined(event.data.message) ?? "Copilot session failed."; + updateProviderSession(context, { + status: "error", + lastError: message, + activeTurnId: undefined, + }); + if (context.activeTurnId) { + await emitTurnCompleted(context, 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) { + if (!event.data.aborted) { + await emitPendingTaskCompletionAsAssistantMessage( + context, + context.activeTurnId, + event, + ); + } + await emitTurnCompleted( + context, + context.activeTurnId, + event.data.aborted ? "cancelled" : "completed", + { + raw: event, + stopReason: event.data.aborted ? "aborted" : null, + }, + ); + } + updateProviderSession(context, { + status: context.stopped ? "closed" : "ready", + activeTurnId: undefined, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: context.stopped ? "stopped" : "ready", + reason: event.data.aborted ? "Copilot turn aborted." : "Copilot idle.", + }, + }); + return; + } + case "session.title_changed": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.metadata.updated", + payload: { + name: event.data.title.trim(), + }, + }); + return; + } + case "session.warning": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "runtime.warning", + payload: { + message: event.data.message.trim(), + 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 "assistant.turn_start": { + const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); + updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "running", + reason: "Copilot turn started", + }, + }); + return; + } + case "assistant.reasoning_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.reasoning": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: event.data.content, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + }, + }); + appendTurnItem(context, turnId, { + type: "reasoning", + reasoningId: event.data.reasoningId, + content: event.data.content, + }); + return; + } + case "assistant.message_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.message": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: event.data.content, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + if (event.data.reasoningText?.trim()) { + const reasoningItemId = `copilot-message-reasoning-${event.data.messageId}`; + await emitTextDelta({ + context, + turnId, + itemId: reasoningItemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: event.data.reasoningText, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId: reasoningItemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + }, + }); + } + appendTurnItem(context, turnId, { + type: "assistant_message", + messageId: event.data.messageId, + content: event.data.content, + }); + return; + } + case "assistant.turn_end": { + const turnId = + context.sdkTurnIdsToTurnIds.get(event.data.turnId) ?? context.activeTurnId; + if (!turnId) { + return; + } + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitTurnCompleted(context, turnId, "completed", { + raw: event, + stopReason: null, + }); + if (context.activeSdkTurnId === event.data.turnId) { + context.activeSdkTurnId = undefined; + } + return; + } + case "assistant.usage": { + const turnId = resolveTurnIdForEvent(context, { + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const usage = usageSnapshotFromAssistantUsage(event); + context.turnUsageByTurnId.set(turnId, usage); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "thread.token-usage.updated", + payload: { + usage, + }, + }); + return; + } + case "abort": { + const turnId = context.activeTurnId; + if (!turnId) { + return; + } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "turn.aborted", + payload: { + reason: event.data.reason, + }, + }); + await emitTurnCompleted(context, turnId, "cancelled", { + raw: event, + stopReason: "aborted", + }); + return; + } + case "tool.execution_start": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const itemType = toolItemType(event.data.toolName, event.data.mcpServerName); + context.toolMetaById.set(event.data.toolCallId, { + toolName: event.data.toolName, + itemType, + }); + context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); + context.startedItemIds.add(itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.started", + payload: { + itemType, + status: "inProgress", + title: event.data.toolName, + ...(event.data.arguments ? { data: event.data.arguments } : {}), + }, + }); + return; + } + case "tool.execution_partial_result": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "content.delta", + payload: { + streamKind: toolStreamKind(toolMeta?.itemType), + delta: event.data.partialOutput, + }, + }); + return; + } + case "tool.execution_progress": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + const 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: event.data.progressMessage.trim(), + }, + }); + return; + } + case "tool.execution_complete": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + const detail = + trimOrUndefined(event.data.result?.detailedContent) ?? + trimOrUndefined(event.data.result?.content) ?? + trimOrUndefined(event.data.error?.message); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: toolMeta?.itemType ?? "dynamic_tool_call", + status: event.data.success ? "completed" : "failed", + title: toolMeta?.toolName ?? "tool", + ...(detail ? { detail } : {}), + ...(event.data.toolTelemetry || event.data.result || event.data.error + ? { + data: { + ...(event.data.result ? { result: event.data.result } : {}), + ...(event.data.error ? { error: event.data.error } : {}), + ...(event.data.toolTelemetry + ? { toolTelemetry: event.data.toolTelemetry } + : {}), + }, + } + : {}), + }, + }); + appendTurnItem(context, turnId, { + type: "tool_execution", + toolCallId: event.data.toolCallId, + toolName: toolMeta?.toolName, + success: event.data.success, + detail, + }); + if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + } + return; + } + case "permission.requested": { + if (event.data.resolvedByHook === true) { + return; + } + const signature = permissionSignature( + event.data.permissionRequest as SessionPermissionRequest & { kind: string }, + ); + const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; + queue.push(event.data); + context.pendingPermissionEventsBySignature.set(signature, queue); + await runWithContext(bindPermissionRequests(context, signature)); + return; + } + case "permission.completed": { + const binding = context.pendingPermissionBindings.get(event.data.requestId); + if (!binding) { + return; + } + context.pendingPermissionBindings.delete(event.data.requestId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: binding.requestId, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: binding.requestType, + decision: event.data.result.kind, + resolution: event.data.result, + }, + }); + 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: { + answer: event.data.answer ?? "", + wasFreeform: event.data.wasFreeform ?? true, + }, + }, + }); + return; + } + case "exit_plan_mode.requested": { + await runWithContext(emitPlanSnapshot(context, event, event.data.planContent)); + return; + } + case "exit_plan_mode.completed": + default: + return; + } + }; + + const startSession: CopilotAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* validationError( + "startSession", + `Expected provider '${PROVIDER}', received '${input.provider}'.`, + ); + } + if ( + input.providerInstanceId !== undefined && + input.providerInstanceId !== boundInstanceId + ) { + return yield* validationError( + "startSession", + `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, + ); + } + + if (sessions.has(input.threadId)) { + yield* stopSessionInternal(input.threadId); + } + + if (!settings.enabled) { + return yield* validationError( + "startSession", + "Copilot is disabled in server settings.", + ); + } + + const cwd = path.resolve(input.cwd ?? serverConfig.cwd); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const reasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ) as CopilotReasoningEffort | undefined; + 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( + input.runtimeMode === "approval-required" + ? DENIED_PERMISSION_RESULT + : APPROVED_PERMISSION_RESULT, + ), + ); + }; + const onSessionUserInputRequest = (_request: CopilotUserInputRequest) => { + return runWithContext( + context + ? onUserInputRequest(context, _request) + : Effect.succeed(EMPTY_USER_INPUT_RESPONSE), + ); + }; + + const client = createCopilotClient({ + settings, + cwd, + ...(options?.environment ? { env: options.environment } : {}), + logLevel: "error", + }); + + const baseSessionConfig = { + clientName: "t3-code", + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + workingDirectory: cwd, + streaming: true, + enableConfigDiscovery: true, + onEvent, + } satisfies Pick< + SessionConfig, + | "clientName" + | "model" + | "reasoningEffort" + | "workingDirectory" + | "streaming" + | "enableConfigDiscovery" + | "onEvent" + >; + + 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, + }); + } + return yield* copilotSdk.createSession(input.threadId, client, { + ...baseSessionConfig, + sessionId: input.threadId, + onPermissionRequest: onSessionPermissionRequest, + onUserInputRequest: onSessionUserInputRequest, + }); + }).pipe( + Effect.tapError(() => + copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore), + ), + ); + + context = { + threadId: input.threadId, + client, + sdkSession, + cwd, + session: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + runtimeMode: input.runtimeMode, + cwd, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + threadId: input.threadId, + resumeCursor: toCopilotResumeCursor(sdkSession.sessionId), + createdAt: nowIso(), + updatedAt: nowIso(), + }, + turns: [], + queuedTurnIds: [], + sdkTurnIdsToTurnIds: new Map(), + completedTurnIds: new Set(), + turnUsageByTurnId: new Map(), + pendingPermissionHandlersBySignature: new Map(), + pendingPermissionEventsBySignature: new Map(), + pendingPermissionBindings: new Map(), + pendingUserInputHandlersBySignature: new Map(), + pendingUserInputEventsBySignature: new Map(), + pendingUserInputBindings: new Map(), + toolMetaById: new Map(), + turnIdByProviderItemId: new Map(), + emittedTextByItemId: new Map(), + pendingTaskCompletionTextByTurnId: new Map(), + turnIdsWithAssistantText: new Set(), + startedItemIds: new Set(), + activeTurnId: undefined, + activeSdkTurnId: undefined, + eventChain: Promise.resolve(), + stopped: false, + }; + sessions.set(input.threadId, context); + + yield* syncSessionMode( + context, + requestedCopilotMode({ + runtimeMode: input.runtimeMode, + }), + ).pipe( + Effect.catch((cause) => + emit({ + ...createBaseEvent({ + threadId: input.threadId, + }), + type: "runtime.warning", + payload: { + message: "Failed to synchronize Copilot mode with the requested runtime mode.", + detail: cause, + }, + }), + ), + ); + + for (const event of earlyEvents) { + enqueueSdkEvent(context, event); + } + yield* Effect.promise(() => context.eventChain).pipe( + Effect.mapError((cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to process Copilot startup events."), + cause, + ), + ), + ); + updateProviderSession(context, { + status: context.session.status === "connecting" ? "ready" : context.session.status, + }); + + return context.session; + }, + ); + + const sendTurn: CopilotAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = yield* requireSessionContext(sessions, input.threadId); + + const text = input.input?.trim(); + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const filePath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!filePath) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: filePath, + displayName: attachment.name, + }); + }); + if ((!text || text.length === 0) && attachments.length === 0) { + return yield* validationError( + "sendTurn", + "Copilot turns require text input or at least one attachment.", + ); + } + + const turnId = TurnId.make(`copilot-turn-${yield* Random.nextUUIDv4}`); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const reasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ) as CopilotReasoningEffort | undefined; + if (modelSelection?.model) { + yield* copilotSdk.setModel( + context, + modelSelection.model, + reasoningEffort, + ); + updateProviderSession(context, { + model: modelSelection.model, + ...(reasoningEffort ? { status: "ready" } : {}), + }); + } + + const mode = requestedCopilotMode({ + runtimeMode: context.session.runtimeMode, + interactionMode: input.interactionMode, + }); + yield* syncSessionMode(context, mode); + + ensureTurnSnapshot(context, turnId); + context.queuedTurnIds.push(turnId); + context.activeTurnId = turnId; + updateProviderSession(context, { + status: "running", + 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 } : {}), + }, + }); + + const messageOptions: MessageOptions = { + prompt: text ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }; + + yield* copilotSdk.send(context, messageOptions).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const queueIndex = context.queuedTurnIds.indexOf(turnId); + if (queueIndex >= 0) { + context.queuedTurnIds.splice(queueIndex, 1); + } + context.activeTurnId = undefined; + updateProviderSession(context, { + status: "ready", + activeTurnId: undefined, + }); + yield* emit({ + ...createBaseEvent({ + threadId: input.threadId, + turnId, + }), + type: "turn.aborted", + payload: { + reason: error.detail, + }, + }); + yield* Effect.tryPromise({ + try: () => + emitTurnCompleted(context, turnId, "failed", { + errorMessage: error.detail, + }), + catch: (cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to emit Copilot turn completion."), + cause, + ), + }); + return yield* error; + }), + ), + ); + + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = yield* requireSessionContext(sessions, threadId); + + yield* copilotSdk.abort(context); + + const targetTurnId = turnId ?? context.activeTurnId; + if (targetTurnId) { + yield* emit({ + ...createBaseEvent({ + threadId, + turnId: targetTurnId, + }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = yield* requireSessionContext(sessions, threadId); + + const binding = context.pendingPermissionBindings.get(requestId); + if (!binding) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const result: PermissionRequestResult = + decision === "accept" || decision === "acceptForSession" + ? { kind: "approved" } + : { kind: "denied-interactively-by-user" }; + yield* Deferred.succeed(binding.deferred, result); + }); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = yield* requireSessionContext(sessions, threadId); + + const binding = context.pendingUserInputBindings.get(requestId); + if (!binding) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "user_input.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + const response = answerFromUserInput(binding, answers); + yield* Deferred.succeed(binding.deferred, response); + }); + + const stopSessionInternal = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return; + } + if (context.stopped) { + sessions.delete(threadId); + return; + } + + context.stopped = true; + yield* settlePendingPermissionHandlers(context); + yield* settlePendingUserInputs(context); + yield* copilotSdk.disconnect(context).pipe(Effect.ignore); + yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + + updateProviderSession(context, { + status: "closed", + activeTurnId: undefined, + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.state.changed", + payload: { + state: "stopped", + reason: "Copilot session stopped.", + }, + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.exited", + payload: { + reason: "Copilot session stopped.", + exitKind: "graceful", + }, + }); + sessions.delete(threadId); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + yield* stopSessionInternal(threadId); + }, + ); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (context) => context.session)); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = yield* requireSessionContext(sessions, threadId); + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }, + ); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, _numTurns) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: "Copilot SDK does not expose thread rollback.", + }); + }, + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.gen(function* () { + yield* Effect.forEach(Array.from(sessions.keys()), stopSessionInternal, { + concurrency: "unbounded", + discard: true, + }); + if (managedNativeEventLogger) { + yield* managedNativeEventLogger.close(); + } + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromPubSub(runtimeEventPubSub); + }, + } satisfies CopilotAdapterShape; +}); + +export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { + return Layer.effect( + CopilotAdapter, + Effect.gen(function* () { + const serverSettingsService = yield* ServerSettingsService; + const settings = yield* Effect.map( + serverSettingsService.getSettings, + (serverSettings) => serverSettings.providers.copilot, + ).pipe(Effect.orDie); + return yield* makeCopilotAdapter(settings, options); + }), + ); +} + +export const CopilotAdapterLive = makeCopilotAdapterLive(); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts new file mode 100644 index 00000000000..76a0f104bd1 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -0,0 +1,147 @@ +import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; +import { Effect } from "effect"; + +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; +import { + authSnapshotFromCopilotSdk, + createCopilotClient, + formatCopilotProbeError, + modelsFromCopilotSdk, + toCopilotProbeError, + versionFromCopilotStatus, +} from "../copilotRuntime.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); +const COPILOT_PRESENTATION = { + displayName: "GitHub Copilot", + showInteractionModeToggle: true, +} as const; + +export function makePendingCopilotProvider(settings: CopilotSettings): ServerProviderDraft { + const checkedAt = new Date().toISOString(); + 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 environment?: NodeJS.ProcessEnv | undefined; +}): Effect.Effect { + if (!input.settings.enabled) { + return Effect.succeed(makePendingCopilotProvider(input.settings)); + } + + const checkedAt = new Date().toISOString(); + const fallback = (cause: unknown, version: string | null = null) => { + 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.acquireUseRelease( + Effect.sync(() => + createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(input.environment ? { env: input.environment } : {}), + logLevel: "error", + }), + ), + (client) => + Effect.tryPromise({ + try: async () => { + 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 })), + ); +} diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 00000000000..24255ef2a70 --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,10 @@ +import { Context } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CopilotAdapterShape extends ProviderAdapterShape {} + +export class CopilotAdapter extends Context.Service()( + "t3/provider/Services/CopilotAdapter", +) {} diff --git a/apps/server/src/provider/Services/CopilotProvider.ts b/apps/server/src/provider/Services/CopilotProvider.ts new file mode 100644 index 00000000000..2dddc77a128 --- /dev/null +++ b/apps/server/src/provider/Services/CopilotProvider.ts @@ -0,0 +1,9 @@ +import { Context } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider.ts"; + +export interface CopilotProviderShape extends ServerProviderShape {} + +export class CopilotProvider extends Context.Service()( + "t3/provider/Services/CopilotProvider", +) {} 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.ts b/apps/server/src/provider/copilotRuntime.ts new file mode 100644 index 00000000000..757c5449338 --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.ts @@ -0,0 +1,273 @@ +import { + CopilotClient, + type CopilotClientOptions, + type GetAuthStatusResponse, + type GetStatusResponse, + type ModelInfo, +} from "@github/copilot-sdk"; +import type { + CopilotSettings, + ModelCapabilities, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("copilot"); + +export const EMPTY_COPILOT_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const COPILOT_REASONING_LABELS = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +} 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"; + +export class CopilotProbePromiseError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + this.name = "CopilotProbePromiseError"; + } +} + +export function trimOrUndefined(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function toCopilotProbeError(cause: unknown): CopilotProbePromiseError { + return new CopilotProbePromiseError(cause); +} + +function describeCopilotProbeCause(cause: unknown): string { + const seen = new Set(); + let current: unknown = cause; + + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + const message = current.message.trim(); + if (message.length > 0 && !GENERIC_EFFECT_TRY_PROMISE_MESSAGES.has(message)) { + return message; + } + current = current.cause; + } + + if (typeof current === "string") { + return current.trim(); + } + + return ""; +} + +function authTypeLabel(authType: GetAuthStatusResponse["authType"]): string | undefined { + switch (authType) { + case "user": + return "Signed-in user"; + 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; + } +} + +export function createCopilotClient(input: { + readonly settings: CopilotSettings; + readonly cwd?: string; + readonly env?: Record; + readonly logLevel?: CopilotClientOptions["logLevel"]; + readonly onListModels?: CopilotClientOptions["onListModels"]; +}) { + return new CopilotClient(buildCopilotClientOptions(input)); +} + +export function buildCopilotClientOptions(input: { + readonly settings: CopilotSettings; + readonly cwd?: string; + readonly env?: Record; + readonly logLevel?: CopilotClientOptions["logLevel"]; + readonly onListModels?: CopilotClientOptions["onListModels"]; +}): CopilotClientOptions { + const cliPath = trimOrUndefined(input.settings.binaryPath); + const cliUrl = trimOrUndefined(input.settings.serverUrl); + const env = { ...process.env }; + + if (input.env) { + Object.assign(env, input.env); + } + + delete env[COPILOT_CLI_PATH_ENV]; + + return { + ...(cliUrl ? { cliUrl } : {}), + ...(!cliUrl && cliPath ? { cliPath } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + env, + ...(input.logLevel ? { logLevel: input.logLevel } : {}), + ...(input.onListModels ? { onListModels: input.onListModels } : {}), + }; +} + +export function versionFromCopilotStatus(status: GetStatusResponse): string | null { + return trimOrUndefined(status.version) ?? null; +} + +export function capabilitiesFromCopilotModel( + model: Pick, +): ModelCapabilities { + const reasoningOptions = + model.supportedReasoningEfforts?.map((effort) => ({ + id: effort, + label: COPILOT_REASONING_LABELS[effort as keyof typeof COPILOT_REASONING_LABELS] ?? effort, + ...(model.defaultReasoningEffort === effort ? { isDefault: true } : {}), + })) ?? []; + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + + return createModelCapabilities({ + optionDescriptors: + reasoningOptions.length > 0 + ? [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }, + ] + : [], + }); +} + +export function modelsFromCopilotSdk(input: { + readonly models: ReadonlyArray; + readonly customModels: ReadonlyArray; +}): ReadonlyArray { + const builtInModels = input.models.map((model) => ({ + slug: model.id.trim(), + name: trimOrUndefined(model.name) ?? model.id.trim(), + isCustom: false, + capabilities: capabilitiesFromCopilotModel(model), + })) satisfies ReadonlyArray; + + return providerModelsFromSettings( + builtInModels, + PROVIDER, + input.customModels, + EMPTY_COPILOT_MODEL_CAPABILITIES, + ); +} + +export function authSnapshotFromCopilotSdk(authStatus: GetAuthStatusResponse): { + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} { + const authType = trimOrUndefined(authStatus.authType); + const label = [ + authTypeLabel(authStatus.authType), + authStatus.login ? `@${authStatus.login}` : undefined, + trimOrUndefined(authStatus.host)?.replace(/^https?:\/\//, ""), + ] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join(" - "); + + 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") + ) { + 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/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts new file mode 100644 index 00000000000..4f0c8c38362 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -0,0 +1,456 @@ +import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; +import { Effect, Exit, Fiber, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + type ChatAttachment, + type CopilotSettings, + type ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { createCopilotClient, trimOrUndefined } from "../provider/copilotRuntime.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { + extractJsonObject, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; + +const COPILOT_TIMEOUT_MS = 180_000; +const COPILOT_TEXT_GENERATION_IDLE_TTL = "30 seconds"; + +type CopilotTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; +type CopilotReasoningEffort = NonNullable; + +interface SharedCopilotTextClientState { + readonly client: CopilotClient; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +interface SharedCopilotTextClientLease { + readonly clientKey: string; + readonly client: CopilotClient; +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +function copilotJsonPrompt(prompt: string, outputSchemaJson: Schema.Top): string { + const schemaDocument = JSON.stringify(toJsonSchemaObject(outputSchemaJson), null, 2); + return `${prompt} + +Return exactly one JSON object matching this schema: +${schemaDocument} + +Do not wrap the JSON in markdown fences or include any other text.`; +} + +function copilotTextGenerationError( + operation: CopilotTextGenerationOperation, + detail: string, + cause?: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function detailFromCause(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; +} + +function copilotTextClientKey(input: { + readonly settings: CopilotSettings; + readonly cwd: string; +}): string { + return JSON.stringify({ + cwd: input.cwd, + binaryPath: trimOrUndefined(input.settings.binaryPath) ?? null, + serverUrl: trimOrUndefined(input.settings.serverUrl) ?? null, + }); +} + +export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")(function* ( + settings: CopilotSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + 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 clientKey = copilotTextClientKey({ + settings: input.settings, + cwd: input.cwd, + }); + + const client = yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const existing = sharedClients.get(clientKey); + if (existing) { + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return existing.client; + } + + const client = createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + env: environment, + logLevel: "error", + }); + yield* Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }); + + sharedClients.set(clientKey, { + client, + activeRequests: 1, + idleCloseFiber: null, + }); + return client; + }), + ); + + return { clientKey, client }; + }); + + const releaseSharedClient = (lease: SharedCopilotTextClientLease) => + sharedClientMutex.withPermit( + Effect.gen(function* () { + const state = sharedClients.get(lease.clientKey); + if (!state || state.client !== lease.client) { + return; + } + + state.activeRequests = Math.max(0, state.activeRequests - 1); + if (state.activeRequests === 0) { + yield* scheduleIdleClose(lease.clientKey, state); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedClientMutex.withPermit( + Effect.forEach([...sharedClients.keys()], (clientKey) => closeSharedClient(clientKey), { + discard: true, + }), + ), + ); + + const runCopilotJson = (input: { + readonly operation: CopilotTextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }): Effect.Effect => + Effect.gen(function* () { + if (!settings.enabled) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "Copilot is disabled in server settings.", + }); + } + + const fileAttachments = (input.attachments ?? []) + .map((attachment) => { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return path + ? { + type: "file" as const, + path, + displayName: attachment.name, + } + : null; + }) + .filter((attachment): attachment is NonNullable => attachment !== null); + const reasoningEffort = getModelSelectionStringOptionValue( + input.modelSelection, + "reasoningEffort", + ) as CopilotReasoningEffort | undefined; + + // Keep request state isolated per generation call while reusing the + // started SDK client so git helpers do not respawn the Copilot CLI. + const rawContent = yield* Effect.acquireUseRelease( + acquireSharedClient({ + operation: input.operation, + cwd: input.cwd, + settings, + }), + ({ client }) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + client.createSession({ + clientName: "t3-code-git-text", + model: input.modelSelection.model, + ...(reasoningEffort ? { reasoningEffort } : {}), + workingDirectory: input.cwd, + streaming: false, + availableTools: [], + enableConfigDiscovery: false, + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to create Copilot session."), + cause, + ), + }), + (session: CopilotSession) => + Effect.tryPromise({ + try: async () => { + const response = await session.sendAndWait( + { + prompt: copilotJsonPrompt(input.prompt, input.outputSchemaJson), + ...(fileAttachments.length > 0 ? { attachments: fileAttachments } : {}), + }, + COPILOT_TIMEOUT_MS, + ); + return response?.data.content.trim() ?? ""; + }, + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Copilot text generation request failed."), + cause, + ), + }), + (session) => + Effect.tryPromise({ + try: () => session.disconnect(), + catch: () => undefined, + }).pipe(Effect.ignore), + ), + releaseSharedClient, + ); + + if (rawContent.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "Copilot returned empty output.", + }); + } + + return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( + extractJsonObject(rawContent), + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Copilot returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "Copilot text generation request failed.", + cause, + }), + ), + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CopilotTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runCopilotJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...(input.includeBranch && "branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CopilotTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runCopilotJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CopilotTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CopilotTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 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/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/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/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..6a672785acb 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,13 +1,22 @@ import { ClaudeSettings, CodexSettings, + CopilotSettings, CursorSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GithubCopilotIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -41,6 +50,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenAI, settingsSchema: CodexSettings, }, + { + value: ProviderDriverKind.make("copilot"), + label: "GitHub Copilot", + icon: GithubCopilotIcon, + settingsSchema: CopilotSettings, + }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", diff --git a/apps/web/src/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/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.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..5ef94742759 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(Schema.String), + serverUrl: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -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), From 659212bcf44ee93ea65ad69f256e6ee6bc846d5e Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:27:31 +0200 Subject: [PATCH 002/104] Include copilot in provider registry test --- apps/server/src/provider/Layers/ProviderRegistry.test.ts | 1 + 1 file changed, 1 insertion(+) 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", From dbd42e108163e8393a199f5c64ad746fffa936c1 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:39:10 +0200 Subject: [PATCH 003/104] Guard Copilot session bootstrap callbacks --- .../provider/Layers/CopilotAdapter.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.test.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts new file mode 100644 index 00000000000..40d0dfef13e --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { + CopilotClient, + CopilotSession, + PermissionRequest, + SessionConfig, +} from "@github/copilot-sdk"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ThreadId } from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +const runtimeMock = vi.hoisted(() => { + const makeSession = () => ({ + sessionId: "copilot-sdk-session-1", + rpc: { + mode: { + set: vi.fn(async () => undefined), + }, + plan: { + read: vi.fn(async () => ({ content: "" })), + }, + }, + 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, + createSessionConfigs: [] as SessionConfig[], + createSessionImpl: null as ((config: SessionConfig) => Promise) | null, + lastSession: makeSession(), + }; + + return { + state, + reset() { + state.startCalls = 0; + state.stopCalls = 0; + state.createSessionConfigs.length = 0; + state.lastSession = makeSession(); + state.createSessionImpl = async () => state.lastSession as unknown as CopilotSession; + }, + }; +}); + +vi.mock("../copilotRuntime.ts", async () => { + const actual = + await vi.importActual("../copilotRuntime.ts"); + + return { + ...actual, + createCopilotClient: vi.fn( + () => + ({ + 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 () => { + throw new Error("resumeSession is not used in CopilotAdapter tests"); + }), + }) as unknown as CopilotClient, + ), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const CopilotAdapterTestLayer = makeCopilotAdapterLive().pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-copilot-adapter-test-", + }), + ), + Layer.provideMerge(ServerSettingsService.layerTest()), + 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) => { + assert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + assert.deepStrictEqual(result, { kind: "denied-interactively-by-user" }); + 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", + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.equal(session.provider, "copilot"); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "approves bootstrap permission requests before the session context exists in full-access mode", + () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config) => { + assert.ok(config.onPermissionRequest); + const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + assert.deepStrictEqual(result, { kind: "approved" }); + 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", + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "copilot"); + 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) => { + assert.ok(config.onUserInputRequest); + const response = await config.onUserInputRequest( + { + question: "How should Copilot continue?", + choices: ["Continue"], + allowFreeform: true, + }, + { sessionId: runtimeMock.state.lastSession.sessionId }, + ); + assert.deepStrictEqual(response, { + answer: "", + wasFreeform: true, + }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-user-input"); + + const session = yield* adapter.startSession({ + provider: "copilot", + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.equal(session.provider, "copilot"); + yield* adapter.stopSession(threadId); + }), + ); +}); From ae50ad18d80738c44ebd2da42be682adec4678c4 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 09:41:31 +0200 Subject: [PATCH 004/104] Refactor Copilot adapter to use Effect deferreds --- .../provider/Layers/CopilotProvider.test.ts | 74 +++++++++++++++++++ .../src/provider/copilotRuntime.test.ts | 48 ++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 apps/server/src/provider/Layers/CopilotProvider.test.ts create mode 100644 apps/server/src/provider/copilotRuntime.test.ts 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..798e98e5c91 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; + +import { it } from "@effect/vitest"; +import { CopilotSettings } from "@t3tools/contracts"; +import { Effect, Schema } from "effect"; +import { beforeEach, describe, vi } from "vitest"; + +import { checkCopilotProviderStatus } from "./CopilotProvider.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + listModelsError: null as Error | null, + }; + + return { + state, + reset() { + state.listModelsError = null; + }, + }; +}); + +vi.mock("../copilotRuntime.ts", async () => { + const actual = + await vi.importActual("../copilotRuntime.ts"); + + return { + ...actual, + createCopilotClient: vi.fn(() => ({ + 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(() => { + runtimeMock.reset(); +}); + +const defaultCopilotSettings: CopilotSettings = Schema.decodeSync(CopilotSettings)({}); + +describe("CopilotProvider status", () => { + it.effect("surfaces underlying SDK errors instead of leaking Effect.tryPromise text", () => + Effect.gen(function* () { + runtimeMock.state.listModelsError = new Error("401 Unauthorized"); + + const snapshot = yield* checkCopilotProviderStatus({ + settings: defaultCopilotSettings, + cwd: process.cwd(), + }); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "401 Unauthorized"); + }), + ); +}); diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts new file mode 100644 index 00000000000..23b257fd170 --- /dev/null +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; + +import { describe, it } from "vitest"; + +import { buildCopilotClientOptions } from "./copilotRuntime.ts"; + +describe("buildCopilotClientOptions", () => { + it("strips inherited COPILOT_CLI_PATH so the SDK uses the bundled CLI by default", () => { + const options = buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "", + serverUrl: "", + customModels: [], + }, + cwd: "/tmp/project", + env: { + PATH: "/usr/bin", + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + GITHUB_TOKEN: "github-token", + }, + logLevel: "error", + }); + + assert.equal(options.cliPath, undefined); + assert.equal(options.cwd, "/tmp/project"); + assert.equal(options.logLevel, "error"); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + assert.equal(options.env?.GITHUB_TOKEN, "github-token"); + }); + + it("prefers the configured binary path over any inherited CLI path override", () => { + const options = buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "/custom/copilot", + serverUrl: "", + customModels: [], + }, + env: { + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + }, + }); + + assert.equal(options.cliPath, "/custom/copilot"); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + }); +}); From 423252ff517d4c485a6ef265ac2b2ccd8ded1738 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:06:41 +0200 Subject: [PATCH 005/104] Use copilot icon in model picker --- .../web/src/components/chat/providerIconUtils.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/web/src/components/chat/providerIconUtils.test.ts diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts new file mode 100644 index 00000000000..8b2a03b3afd --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { GithubCopilotIcon } from "../Icons"; +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; + +describe("providerIconUtils", () => { + it("uses the dedicated GitHub Copilot icon for the copilot provider", () => { + expect(PROVIDER_ICON_BY_PROVIDER[ProviderDriverKind.make("copilot")]).toBe(GithubCopilotIcon); + }); +}); From 2c0efe99122be2a8a1b1f340fc3678e6217025e5 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:23:52 +0200 Subject: [PATCH 006/104] Reuse Copilot git text clients --- .../CopilotTextGeneration.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/server/src/textGeneration/CopilotTextGeneration.test.ts diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts new file mode 100644 index 00000000000..f57d8ebe491 --- /dev/null +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -0,0 +1,128 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { beforeEach, expect, vi } from "vitest"; + +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 client: { + readonly start: ReturnType; + readonly stop: ReturnType; + readonly createSession: ReturnType; + }; + }>, + sessions: [] as Array<{ + readonly disconnect: ReturnType; + readonly sendAndWait: ReturnType; + }>, + }; + + return { + state, + reset() { + state.createdClients = []; + state.sessions = []; + }, + }; +}); + +vi.mock("../provider/copilotRuntime.ts", async () => { + const actual = await vi.importActual( + "../provider/copilotRuntime.ts", + ); + + return { + ...actual, + createCopilotClient: vi.fn((input: { readonly cwd?: string }) => { + const start = vi.fn(async () => undefined); + const stop = vi.fn(async () => undefined); + const createSession = vi.fn(async () => { + const sendAndWait = vi.fn(async () => ({ + data: { + content: JSON.stringify({ + subject: "Add change", + body: "", + }), + }, + })); + const disconnect = vi.fn(async () => undefined); + runtimeMock.state.sessions.push({ disconnect, sendAndWait }); + return { + sendAndWait, + disconnect, + }; + }); + + const client = { + start, + stop, + createSession, + }; + runtimeMock.state.createdClients.push({ input, client }); + return 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.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); + }), + ); +}); From 8ba5c112c45d3ed0faf23d77eaf38e26ba73bc98 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:26:09 +0200 Subject: [PATCH 007/104] Delete apps/web/src/components/chat/providerIconUtils.test.ts --- .../web/src/components/chat/providerIconUtils.test.ts | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 apps/web/src/components/chat/providerIconUtils.test.ts diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts deleted file mode 100644 index 8b2a03b3afd..00000000000 --- a/apps/web/src/components/chat/providerIconUtils.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ProviderDriverKind } from "@t3tools/contracts"; - -import { GithubCopilotIcon } from "../Icons"; -import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; - -describe("providerIconUtils", () => { - it("uses the dedicated GitHub Copilot icon for the copilot provider", () => { - expect(PROVIDER_ICON_BY_PROVIDER[ProviderDriverKind.make("copilot")]).toBe(GithubCopilotIcon); - }); -}); From cf7738fb809b2f8235e49c63a0d419402d1674cb Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 22 Apr 2026 12:56:11 +0200 Subject: [PATCH 008/104] Render Copilot task completion as assistant text --- .../provider/Layers/CopilotAdapter.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 40d0dfef13e..2f889945be5 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -6,6 +6,7 @@ import type { CopilotSession, PermissionRequest, SessionConfig, + SessionEvent, } from "@github/copilot-sdk"; import { it } from "@effect/vitest"; import { Effect, Layer } from "effect"; @@ -19,6 +20,8 @@ import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const waitForSdkEventQueue = () => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); const runtimeMock = vi.hoisted(() => { const makeSession = () => ({ @@ -192,4 +195,111 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect( + "renders Copilot Task_complete tool output as assistant text when no assistant message arrives", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-task-complete-assistant-fallback"); + + yield* adapter.startSession({ + provider: "copilot", + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "make an architecture diagram", + attachments: [], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const resultText = + "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; + + emit({ + id: "evt-copilot-turn-start", + timestamp: new Date().toISOString(), + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-1", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-start", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-task-complete", + toolName: "Task_complete", + arguments: {}, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-complete", + timestamp: new Date().toISOString(), + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-task-complete", + success: true, + result: { + content: resultText, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle", + timestamp: new Date().toISOString(), + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + let thread = yield* adapter.readThread(threadId); + for ( + let attempt = 0; + attempt < 20 && + !thread.turns.some((entry) => + entry.items.some( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + thread = yield* adapter.readThread(threadId); + } + + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItem = turnSnapshot.items.find( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItem, { + type: "assistant_message", + messageId: `copilot-task-completion-${String(turn.turnId)}`, + content: resultText, + }); + + yield* adapter.stopSession(threadId); + }), + ); }); From dbbab19c3dadf3b0d46e6ae011bc7fa3aa7f4dca Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 7 May 2026 15:02:19 +0200 Subject: [PATCH 009/104] Complete Copilot send failure lifecycle --- .../provider/Layers/CopilotAdapter.test.ts | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 2f889945be5..d2101134343 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -9,10 +9,10 @@ import type { SessionEvent, } from "@github/copilot-sdk"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Fiber, Layer, Stream } from "effect"; import { beforeEach, vi } from "vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -302,4 +302,51 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + 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", + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.send.mockRejectedValueOnce(new Error("Copilot send rejected")); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const result = yield* adapter + .sendTurn({ + threadId, + input: "trigger send failure", + attachments: [], + }) + .pipe(Effect.result); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(result._tag, "Failure"); + const aborted = runtimeEvents.find((event) => event.type === "turn.aborted"); + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + + assert.equal(aborted?.type, "turn.aborted"); + assert.equal(completed?.type, "turn.completed"); + if (aborted?.type === "turn.aborted" && completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(aborted.turnId)); + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Copilot send rejected"); + } + + yield* adapter.stopSession(threadId); + }), + ); }); From 1b95d478f0d0bbc1e9e07aa19438f7cfe258f071 Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 7 May 2026 15:45:45 +0200 Subject: [PATCH 010/104] Fix Copilot rebase fallout --- .../provider/Layers/CopilotAdapter.test.ts | 13 +- .../src/provider/Layers/CopilotAdapter.ts | 3293 ++++++++--------- 2 files changed, 1640 insertions(+), 1666 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index d2101134343..8a95d7eb9be 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -12,7 +12,7 @@ import { it } from "@effect/vitest"; import { Effect, Fiber, Layer, Stream } from "effect"; import { beforeEach, vi } from "vitest"; -import { type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { type ProviderRuntimeEvent, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -20,6 +20,7 @@ import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const COPILOT_DRIVER = ProviderDriverKind.make("copilot"); const waitForSdkEventQueue = () => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); @@ -121,7 +122,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const threadId = asThreadId("copilot-bootstrap-permission-denied"); const session = yield* adapter.startSession({ - provider: "copilot", + provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), runtimeMode: "approval-required", @@ -149,7 +150,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const threadId = asThreadId("copilot-bootstrap-permission-approved"); const session = yield* adapter.startSession({ - provider: "copilot", + provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), runtimeMode: "full-access", @@ -185,7 +186,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const threadId = asThreadId("copilot-bootstrap-user-input"); const session = yield* adapter.startSession({ - provider: "copilot", + provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), runtimeMode: "approval-required", @@ -204,7 +205,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const threadId = asThreadId("copilot-task-complete-assistant-fallback"); yield* adapter.startSession({ - provider: "copilot", + provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), runtimeMode: "approval-required", @@ -309,7 +310,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const threadId = asThreadId("copilot-send-failure-turn-completed"); yield* adapter.startSession({ - provider: "copilot", + provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), runtimeMode: "approval-required", diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 97ef9d9169b..0ca3ddb0993 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -636,1777 +636,1750 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( settings: CopilotSettings, options?: CopilotAdapterLiveOptions, ) { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); - const serverConfig = yield* ServerConfig; - const runtimeEventPubSub = yield* PubSub.unbounded(); - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const sessions = new Map(); - const path = yield* Path.Path; - const runtimeContext = yield* Effect.context(); - const runWithContext = Effect.runPromiseWith(runtimeContext); - - const emit = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); - const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => - nativeEventLogger - ? runWithContext( - nativeEventLogger.write({ source: "copilot.sdk.event", payload: event }, threadId), - ) - : Promise.resolve(); - - const copilotSdk = { - startClient: (threadId: ThreadId, client: CopilotClient) => - Effect.tryPromise({ - try: () => client.start(), - catch: (cause) => - processError( - threadId, - detailFromCause(cause, "Failed to start Copilot client."), - cause, - ), - }), - stopClient: (threadId: ThreadId, client: CopilotClient) => - Effect.tryPromise({ - try: () => client.stop(), - catch: (cause) => - processError( - threadId, - detailFromCause(cause, "Failed to stop Copilot client."), - cause, - ), - }), - createSession: ( - threadId: ThreadId, - client: CopilotClient, - config: SessionConfig, - ): Effect.Effect => - Effect.tryPromise({ - try: () => client.createSession(config), - catch: (cause) => - processError( - threadId, - detailFromCause(cause, "Failed to create Copilot session."), - cause, - ), - }), - resumeSession: ( - threadId: ThreadId, - client: CopilotClient, - sessionId: string, - config: SessionConfig, - ): Effect.Effect => - Effect.tryPromise({ - try: () => client.resumeSession(sessionId, config), - catch: (cause) => - processError( - threadId, - detailFromCause(cause, "Failed to resume Copilot session."), - cause, - ), + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("copilot"); + const serverConfig = yield* ServerConfig; + const runtimeEventPubSub = yield* PubSub.unbounded(); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const sessions = new Map(); + const path = yield* Path.Path; + const runtimeContext = yield* Effect.context(); + const runWithContext = Effect.runPromiseWith(runtimeContext); + + const emit = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); + const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => + nativeEventLogger + ? runWithContext( + nativeEventLogger.write({ source: "copilot.sdk.event", payload: event }, threadId), + ) + : Promise.resolve(); + + const copilotSdk = { + startClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + processError(threadId, detailFromCause(cause, "Failed to start Copilot client."), cause), + }), + stopClient: (threadId: ThreadId, client: CopilotClient) => + Effect.tryPromise({ + try: () => client.stop(), + catch: (cause) => + processError(threadId, detailFromCause(cause, "Failed to stop Copilot client."), cause), + }), + createSession: ( + threadId: ThreadId, + client: CopilotClient, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.createSession(config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to create Copilot session."), + cause, + ), + }), + resumeSession: ( + threadId: ThreadId, + client: CopilotClient, + sessionId: string, + config: SessionConfig, + ): Effect.Effect => + Effect.tryPromise({ + try: () => client.resumeSession(sessionId, config), + catch: (cause) => + processError( + threadId, + detailFromCause(cause, "Failed to resume Copilot session."), + cause, + ), + }), + setMode: ( + context: CopilotSessionContext, + mode: CopilotMode, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.rpc.mode.set({ mode }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.mode.set", + detail: detailFromCause(cause, "Failed to update Copilot mode."), + cause, }), - 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, }), - 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, - }), + }), + setModel: ( + context: CopilotSessionContext, + model: string, + reasoningEffort?: CopilotReasoningEffort | undefined, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.setModel(model, reasoningEffort ? { reasoningEffort } : {}), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.setModel", + detail: detailFromCause(cause, "Failed to update Copilot model."), + cause, }), - setModel: ( - context: CopilotSessionContext, - model: string, - reasoningEffort?: CopilotReasoningEffort | undefined, - ): Effect.Effect => - Effect.tryPromise({ - try: () => - context.sdkSession.setModel(model, reasoningEffort ? { reasoningEffort } : {}), - 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, }), - 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, }), - 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, }), - 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, }), - } 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, - }, - }, - }); - }); - }; + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: { + error, + sourceEventType: event.type, + }, + }, + }); + }); + }; + + const emitTurnCompleted = async ( + context: CopilotSessionContext, + turnId: TurnId, + status: ProviderRuntimeTurnStatus, + input?: { + readonly stopReason?: string | null | undefined; + readonly errorMessage?: string | undefined; + readonly raw?: SessionEvent | undefined; + }, + ) => { + if (context.completedTurnIds.has(turnId)) { + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + return; + } + context.completedTurnIds.add(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } + updateProviderSession(context, { + status: status === "failed" ? "error" : context.stopped ? "closed" : "ready", + ...(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 emitTurnCompleted = async ( - context: CopilotSessionContext, - turnId: TurnId, - status: ProviderRuntimeTurnStatus, - input?: { - readonly stopReason?: string | null | undefined; - readonly errorMessage?: string | undefined; - readonly raw?: SessionEvent | undefined; + 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", }, - ) => { - if (context.completedTurnIds.has(turnId)) { - context.pendingTaskCompletionTextByTurnId.delete(turnId); - context.turnIdsWithAssistantText.delete(turnId); - return; - } - context.completedTurnIds.add(turnId); - context.pendingTaskCompletionTextByTurnId.delete(turnId); - context.turnIdsWithAssistantText.delete(turnId); - if (context.activeTurnId === turnId) { - context.activeTurnId = undefined; + }); + } + + 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); + } + 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 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, + }); + }; + + const emitPermissionRequestOpened = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + data: SessionPermissionRequestedEvent["data"], + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: pending.requestId, + raw: { + ...({ + id: pending.requestId, + timestamp: nowIso(), + parentId: null, + ephemeral: true, + type: "permission.requested", + data, + } satisfies SessionPermissionRequestedEvent), + }, + }), + type: "request.opened", + payload: { + requestType: pending.requestType, + ...(permissionDetail(data.permissionRequest) + ? { detail: permissionDetail(data.permissionRequest) } + : {}), + args: data.permissionRequest, + }, + }); + + const 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(); + context.pendingPermissionBindings.set(requestId, { + requestId, + requestType: mapPermissionRequestType(eventData.permissionRequest), + deferred: handler.deferred, + }); + if ( + context.session.runtimeMode === "approval-required" && + 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 onPermissionRequest = ( + context: CopilotSessionContext, + request: PermissionRequest, + ): Effect.Effect => + Effect.gen(function* () { + if (context.session.runtimeMode !== "approval-required") { + return APPROVED_PERMISSION_RESULT; + } + if (context.stopped) { + return DENIED_PERMISSION_RESULT; + } + + const signature = permissionSignature(request as PermissionRequest & { kind: string }); + 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, + }, + }, + }), + ), + ); + + const emitPlanSnapshot = ( + context: CopilotSessionContext, + raw: SessionEvent, + fallbackPlan?: string | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const turnId = context.activeTurnId ?? latestTurnId(context); + if (!turnId) { + return; + } + const plan = fallbackPlan + ? fallbackPlan.trim() + : (yield* copilotSdk.readPlan(context)).trim(); + if (plan.length === 0) { + return; + } + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.proposed.completed", + payload: { + planMarkdown: plan, + }, + }); + }); + + const handleSdkEvent = async ( + context: CopilotSessionContext, + event: SessionEvent, + ): Promise => { + switch (event.type) { + case "session.start": { updateProviderSession(context, { - status: status === "failed" ? "error" : context.stopped ? "closed" : "ready", - ...(status === "failed" && input?.errorMessage ? { lastError: input.errorMessage } : {}), - activeTurnId: undefined, + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(event.data.sessionId), }); await emitAsync({ ...createBaseEvent({ threadId: context.threadId, - turnId, - raw: input?.raw, + raw: event, }), - type: "turn.completed", + type: "session.started", payload: { - state: status, - ...(input?.stopReason !== undefined ? { stopReason: input.stopReason } : {}), - ...(context.turnUsageByTurnId.has(turnId) - ? { usage: context.turnUsageByTurnId.get(turnId) } - : {}), - ...(input?.errorMessage ? { errorMessage: input.errorMessage } : {}), + message: "Copilot session started.", + resume: toCopilotResumeCursor(event.data.sessionId), }, }); - }; - - 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", + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, }, - }); - } - - 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); - } + }, + }); await emitAsync({ ...createBaseEvent({ - threadId: input.context.threadId, - turnId: input.turnId, - itemId: input.itemId, - raw: input.raw, + threadId: context.threadId, + raw: event, }), - type: "content.delta", + type: "session.state.changed", payload: { - streamKind: input.streamKind, - delta, + state: "ready", + reason: "Copilot session ready", }, }); - }; - - 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 emitAsync({ ...createBaseEvent({ threadId: context.threadId, - turnId, - itemId, - raw, + raw: event, }), - type: "item.completed", + type: "thread.started", payload: { - itemType: "assistant_message", - status: "completed", - detail: content, + providerThreadId: event.data.sessionId, }, }); - appendTurnItem(context, turnId, { - type: "assistant_message", - messageId: itemId, - content, + return; + } + case "session.resume": { + updateProviderSession(context, { + status: "ready", + model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, + ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), + resumeCursor: toCopilotResumeCursor(context.sdkSession.sessionId), }); - }; - - const emitPermissionRequestOpened = ( - context: CopilotSessionContext, - pending: PendingPermissionBinding, - data: SessionPermissionRequestedEvent["data"], - ): Effect.Effect => - emit({ + await emitAsync({ ...createBaseEvent({ threadId: context.threadId, - requestId: pending.requestId, - raw: { - ...({ - id: pending.requestId, - timestamp: nowIso(), - parentId: null, - ephemeral: true, - type: "permission.requested", - data, - } satisfies SessionPermissionRequestedEvent), - }, + raw: event, }), - type: "request.opened", + type: "session.started", payload: { - requestType: pending.requestType, - ...(permissionDetail(data.permissionRequest) - ? { detail: permissionDetail(data.permissionRequest) } - : {}), - args: data.permissionRequest, + message: "Copilot session resumed.", + resume: toCopilotResumeCursor(context.sdkSession.sessionId), }, }); - - 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 } : {}), + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.configured", + payload: { + config: { + model: event.data.selectedModel ?? null, + reasoningEffort: event.data.reasoningEffort ?? null, + cwd: event.data.context?.cwd ?? context.cwd, + }, }, - ]; - return emit({ + }); + await emitAsync({ ...createBaseEvent({ threadId: context.threadId, - requestId, - raw, + raw: event, }), - type: "user-input.requested", + type: "session.state.changed", payload: { - questions, + state: "ready", + reason: "Copilot session resumed", }, }); - }; - - 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(); - context.pendingPermissionBindings.set(requestId, { - requestId, - requestType: mapPermissionRequestType(eventData.permissionRequest), - deferred: handler.deferred, - }); - if ( - context.session.runtimeMode === "approval-required" && - 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); - } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.started", + payload: { + providerThreadId: context.sdkSession.sessionId, + }, }); - - 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); - } + return; + } + case "session.error": { + const message = trimOrUndefined(event.data.message) ?? "Copilot session failed."; + updateProviderSession(context, { + status: "error", + lastError: message, + activeTurnId: undefined, }); - - const onPermissionRequest = ( - context: CopilotSessionContext, - request: PermissionRequest, - ): Effect.Effect => - Effect.gen(function* () { - if (context.session.runtimeMode !== "approval-required") { - return APPROVED_PERMISSION_RESULT; - } - if (context.stopped) { - return DENIED_PERMISSION_RESULT; - } - - const signature = permissionSignature(request as PermissionRequest & { kind: string }); - const deferred = yield* Deferred.make(); - const queue = context.pendingPermissionHandlersBySignature.get(signature) ?? []; - queue.push({ - signature, - deferred, + if (context.activeTurnId) { + await emitTurnCompleted(context, context.activeTurnId, "failed", { + errorMessage: message, + raw: event, }); - context.pendingPermissionHandlersBySignature.set(signature, queue); - yield* bindPermissionRequests(context, signature); - return yield* Deferred.await(deferred); + } + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.data, + }, }); - - 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); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "error", + reason: message, + detail: event.data, + }, }); - - 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, - }, - }, - }), - ), - ); - - 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; + return; + } + case "session.idle": { + if (context.activeTurnId) { + if (!event.data.aborted) { + await emitPendingTaskCompletionAsAssistantMessage(context, context.activeTurnId, event); } - yield* emit({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw, - }), - type: "turn.proposed.completed", - payload: { - planMarkdown: plan, + await emitTurnCompleted( + context, + context.activeTurnId, + event.data.aborted ? "cancelled" : "completed", + { + raw: event, + stopReason: event.data.aborted ? "aborted" : null, }, - }); + ); + } + updateProviderSession(context, { + status: context.stopped ? "closed" : "ready", + activeTurnId: undefined, }); - - const handleSdkEvent = async ( - context: CopilotSessionContext, - event: SessionEvent, - ): Promise => { - switch (event.type) { - case "session.start": { - updateProviderSession(context, { - status: "ready", - model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, - ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), - resumeCursor: toCopilotResumeCursor(event.data.sessionId), - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.started", - payload: { - message: "Copilot session started.", - resume: toCopilotResumeCursor(event.data.sessionId), - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.configured", - payload: { - config: { - model: event.data.selectedModel ?? null, - reasoningEffort: event.data.reasoningEffort ?? null, - cwd: event.data.context?.cwd ?? context.cwd, - }, - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: "ready", - reason: "Copilot session ready", - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.started", - payload: { - providerThreadId: event.data.sessionId, - }, - }); - return; - } - case "session.resume": { - updateProviderSession(context, { - status: "ready", - model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, - ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), - resumeCursor: toCopilotResumeCursor(context.sdkSession.sessionId), - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.started", - payload: { - message: "Copilot session resumed.", - resume: toCopilotResumeCursor(context.sdkSession.sessionId), - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.configured", - payload: { - config: { - model: event.data.selectedModel ?? null, - reasoningEffort: event.data.reasoningEffort ?? null, - cwd: event.data.context?.cwd ?? context.cwd, - }, - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: "ready", - reason: "Copilot session resumed", - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.started", - payload: { - providerThreadId: context.sdkSession.sessionId, - }, - }); - return; - } - case "session.error": { - const message = trimOrUndefined(event.data.message) ?? "Copilot session failed."; - updateProviderSession(context, { - status: "error", - lastError: message, - activeTurnId: undefined, - }); - if (context.activeTurnId) { - await emitTurnCompleted(context, 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) { - if (!event.data.aborted) { - await emitPendingTaskCompletionAsAssistantMessage( - context, - context.activeTurnId, - event, - ); - } - await emitTurnCompleted( - context, - context.activeTurnId, - event.data.aborted ? "cancelled" : "completed", - { - raw: event, - stopReason: event.data.aborted ? "aborted" : null, - }, - ); - } - updateProviderSession(context, { - status: context.stopped ? "closed" : "ready", - activeTurnId: undefined, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: context.stopped ? "stopped" : "ready", - reason: event.data.aborted ? "Copilot turn aborted." : "Copilot idle.", - }, - }); - return; - } - case "session.title_changed": { - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.metadata.updated", - payload: { - name: event.data.title.trim(), - }, - }); - return; - } - case "session.warning": { - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "runtime.warning", - payload: { - message: event.data.message.trim(), - 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 "assistant.turn_start": { - const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); - updateProviderSession(context, { - status: "running", - activeTurnId: turnId, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: "running", - reason: "Copilot turn started", - }, - }); - return; - } - case "assistant.reasoning_delta": { - const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, - providerItemId: event.data.reasoningId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-reasoning-${event.data.reasoningId}`; - context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); - await emitTextDelta({ - context, + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: context.stopped ? "stopped" : "ready", + reason: event.data.aborted ? "Copilot turn aborted." : "Copilot idle.", + }, + }); + return; + } + case "session.title_changed": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "thread.metadata.updated", + payload: { + name: event.data.title.trim(), + }, + }); + return; + } + case "session.warning": { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + raw: event, + }), + type: "runtime.warning", + payload: { + message: event.data.message.trim(), + 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 "assistant.turn_start": { + const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); + updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw: event, + }), + type: "session.state.changed", + payload: { + state: "running", + reason: "Copilot turn started", + }, + }); + return; + } + case "assistant.reasoning_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.reasoning": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.reasoningId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-reasoning-${event.data.reasoningId}`; + context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "reasoning", + streamKind: "reasoning_text", + nextText: event.data.content, + raw: event, + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + }, + }); + appendTurnItem(context, turnId, { + type: "reasoning", + reasoningId: event.data.reasoningId, + content: event.data.content, + }); + return; + } + case "assistant.message_delta": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + raw: event, + }); + return; + } + case "assistant.message": { + const turnId = resolveTurnIdForEvent(context, { + sdkTurnId: context.activeSdkTurnId, + providerItemId: event.data.messageId, + parentProviderItemId: event.data.parentToolCallId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-message-${event.data.messageId}`; + context.turnIdByProviderItemId.set(event.data.messageId, turnId); + 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, - itemType: "reasoning", - streamKind: "reasoning_text", - nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + itemId: reasoningItemId, raw: event, - }); - return; - } - case "assistant.reasoning": { - const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, - providerItemId: event.data.reasoningId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-reasoning-${event.data.reasoningId}`; - context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); - await emitTextDelta({ - context, - turnId, - itemId, + }), + type: "item.completed", + payload: { itemType: "reasoning", - streamKind: "reasoning_text", - nextText: event.data.content, - raw: event, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - type: "item.completed", - payload: { - itemType: "reasoning", - status: "completed", - }, - }); - appendTurnItem(context, turnId, { - type: "reasoning", - reasoningId: event.data.reasoningId, - content: event.data.content, - }); - return; - } - case "assistant.message_delta": { - const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, - providerItemId: event.data.messageId, - parentProviderItemId: event.data.parentToolCallId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-message-${event.data.messageId}`; - context.turnIdByProviderItemId.set(event.data.messageId, turnId); - await emitTextDelta({ - context, - turnId, - itemId, - itemType: "assistant_message", - streamKind: "assistant_text", - nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, - raw: event, - }); - return; - } - case "assistant.message": { - const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, - providerItemId: event.data.messageId, - parentProviderItemId: event.data.parentToolCallId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-message-${event.data.messageId}`; - context.turnIdByProviderItemId.set(event.data.messageId, turnId); - await emitTextDelta({ - context, - turnId, - itemId, - itemType: "assistant_message", - streamKind: "assistant_text", - nextText: event.data.content, - raw: event, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - type: "item.completed", - payload: { - itemType: "assistant_message", - status: "completed", - }, - }); - if (event.data.reasoningText?.trim()) { - const reasoningItemId = `copilot-message-reasoning-${event.data.messageId}`; - await emitTextDelta({ - context, - turnId, - itemId: reasoningItemId, - itemType: "reasoning", - streamKind: "reasoning_text", - nextText: event.data.reasoningText, - raw: event, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId: reasoningItemId, - raw: event, - }), - type: "item.completed", - payload: { - itemType: "reasoning", - status: "completed", - }, - }); - } - appendTurnItem(context, turnId, { - type: "assistant_message", - messageId: event.data.messageId, - content: event.data.content, - }); - return; - } - case "assistant.turn_end": { - const turnId = - context.sdkTurnIdsToTurnIds.get(event.data.turnId) ?? context.activeTurnId; - if (!turnId) { - return; - } - await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); - await emitTurnCompleted(context, turnId, "completed", { - raw: event, - stopReason: null, - }); - if (context.activeSdkTurnId === event.data.turnId) { - context.activeSdkTurnId = undefined; - } - return; - } - case "assistant.usage": { - const turnId = resolveTurnIdForEvent(context, { - parentProviderItemId: event.data.parentToolCallId, - sdkTurnId: context.activeSdkTurnId, - }); - if (!turnId) { - return; - } - const usage = usageSnapshotFromAssistantUsage(event); - context.turnUsageByTurnId.set(turnId, usage); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw: event, - }), - type: "thread.token-usage.updated", - payload: { - usage, - }, - }); - return; - } - case "abort": { - const turnId = context.activeTurnId; - if (!turnId) { - return; - } - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw: event, - }), - type: "turn.aborted", - payload: { - reason: event.data.reason, - }, - }); - await emitTurnCompleted(context, turnId, "cancelled", { - raw: event, - stopReason: "aborted", - }); - return; - } - case "tool.execution_start": { - const turnId = resolveTurnIdForEvent(context, { - providerItemId: event.data.toolCallId, - parentProviderItemId: event.data.parentToolCallId, - sdkTurnId: context.activeSdkTurnId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-tool-${event.data.toolCallId}`; - const itemType = toolItemType(event.data.toolName, event.data.mcpServerName); - context.toolMetaById.set(event.data.toolCallId, { - toolName: event.data.toolName, - itemType, - }); - context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); - context.startedItemIds.add(itemId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - type: "item.started", - payload: { - itemType, - status: "inProgress", - title: event.data.toolName, - ...(event.data.arguments ? { data: event.data.arguments } : {}), - }, - }); - return; - } - case "tool.execution_partial_result": { - const turnId = resolveTurnIdForEvent(context, { - providerItemId: event.data.toolCallId, - sdkTurnId: context.activeSdkTurnId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-tool-${event.data.toolCallId}`; - const toolMeta = context.toolMetaById.get(event.data.toolCallId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - type: "content.delta", - payload: { - streamKind: toolStreamKind(toolMeta?.itemType), - delta: event.data.partialOutput, - }, - }); - return; - } - case "tool.execution_progress": { - const turnId = resolveTurnIdForEvent(context, { - providerItemId: event.data.toolCallId, - sdkTurnId: context.activeSdkTurnId, - }); - const 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: event.data.progressMessage.trim(), - }, - }); - return; - } - case "tool.execution_complete": { - const turnId = resolveTurnIdForEvent(context, { - providerItemId: event.data.toolCallId, - parentProviderItemId: event.data.parentToolCallId, - sdkTurnId: context.activeSdkTurnId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-tool-${event.data.toolCallId}`; - const toolMeta = context.toolMetaById.get(event.data.toolCallId); - const detail = - trimOrUndefined(event.data.result?.detailedContent) ?? - trimOrUndefined(event.data.result?.content) ?? - trimOrUndefined(event.data.error?.message); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - type: "item.completed", - payload: { - itemType: toolMeta?.itemType ?? "dynamic_tool_call", - status: event.data.success ? "completed" : "failed", - title: toolMeta?.toolName ?? "tool", - ...(detail ? { detail } : {}), - ...(event.data.toolTelemetry || event.data.result || event.data.error - ? { - data: { - ...(event.data.result ? { result: event.data.result } : {}), - ...(event.data.error ? { error: event.data.error } : {}), - ...(event.data.toolTelemetry - ? { toolTelemetry: event.data.toolTelemetry } - : {}), - }, - } - : {}), - }, - }); - appendTurnItem(context, turnId, { - type: "tool_execution", - toolCallId: event.data.toolCallId, - toolName: toolMeta?.toolName, - success: event.data.success, - detail, - }); - if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { - context.pendingTaskCompletionTextByTurnId.set(turnId, detail); - } - return; - } - case "permission.requested": { - if (event.data.resolvedByHook === true) { - return; - } - const signature = permissionSignature( - event.data.permissionRequest as SessionPermissionRequest & { kind: string }, - ); - const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; - queue.push(event.data); - context.pendingPermissionEventsBySignature.set(signature, queue); - await runWithContext(bindPermissionRequests(context, signature)); - return; - } - case "permission.completed": { - const binding = context.pendingPermissionBindings.get(event.data.requestId); - if (!binding) { - return; - } - context.pendingPermissionBindings.delete(event.data.requestId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - requestId: binding.requestId, - raw: event, - }), - type: "request.resolved", - payload: { - requestType: binding.requestType, - decision: event.data.result.kind, - resolution: event.data.result, - }, - }); - 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: { - answer: event.data.answer ?? "", - wasFreeform: event.data.wasFreeform ?? true, - }, - }, - }); - return; - } - case "exit_plan_mode.requested": { - await runWithContext(emitPlanSnapshot(context, event, event.data.planContent)); - return; - } - case "exit_plan_mode.completed": - default: - return; - } - }; - - const startSession: CopilotAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* validationError( - "startSession", - `Expected provider '${PROVIDER}', received '${input.provider}'.`, - ); - } - if ( - input.providerInstanceId !== undefined && - input.providerInstanceId !== boundInstanceId - ) { - return yield* validationError( - "startSession", - `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, - ); - } - - if (sessions.has(input.threadId)) { - yield* stopSessionInternal(input.threadId); - } - - if (!settings.enabled) { - return yield* validationError( - "startSession", - "Copilot is disabled in server settings.", - ); - } - - const cwd = path.resolve(input.cwd ?? serverConfig.cwd); - const modelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const reasoningEffort = getModelSelectionStringOptionValue( - modelSelection, - "reasoningEffort", - ) as CopilotReasoningEffort | undefined; - 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( - input.runtimeMode === "approval-required" - ? DENIED_PERMISSION_RESULT - : APPROVED_PERMISSION_RESULT, - ), - ); - }; - const onSessionUserInputRequest = (_request: CopilotUserInputRequest) => { - return runWithContext( - context - ? onUserInputRequest(context, _request) - : Effect.succeed(EMPTY_USER_INPUT_RESPONSE), - ); - }; - - const client = createCopilotClient({ - settings, - cwd, - ...(options?.environment ? { env: options.environment } : {}), - logLevel: "error", - }); - - const baseSessionConfig = { - clientName: "t3-code", - ...(modelSelection?.model ? { model: modelSelection.model } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - workingDirectory: cwd, - streaming: true, - enableConfigDiscovery: true, - onEvent, - } satisfies Pick< - SessionConfig, - | "clientName" - | "model" - | "reasoningEffort" - | "workingDirectory" - | "streaming" - | "enableConfigDiscovery" - | "onEvent" - >; - - 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, - }); - } - return yield* copilotSdk.createSession(input.threadId, client, { - ...baseSessionConfig, - sessionId: input.threadId, - onPermissionRequest: onSessionPermissionRequest, - onUserInputRequest: onSessionUserInputRequest, - }); - }).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(), + status: "completed", }, - turns: [], - queuedTurnIds: [], - sdkTurnIdsToTurnIds: new Map(), - completedTurnIds: new Set(), - turnUsageByTurnId: new Map(), - pendingPermissionHandlersBySignature: new Map(), - pendingPermissionEventsBySignature: new Map(), - pendingPermissionBindings: new Map(), - pendingUserInputHandlersBySignature: new Map(), - pendingUserInputEventsBySignature: new Map(), - pendingUserInputBindings: new Map(), - toolMetaById: new Map(), - turnIdByProviderItemId: new Map(), - emittedTextByItemId: new Map(), - pendingTaskCompletionTextByTurnId: new Map(), - turnIdsWithAssistantText: new Set(), - startedItemIds: new Set(), - activeTurnId: undefined, - activeSdkTurnId: undefined, - eventChain: Promise.resolve(), - stopped: false, - }; - sessions.set(input.threadId, context); - - yield* syncSessionMode( - context, - requestedCopilotMode({ - runtimeMode: input.runtimeMode, - }), - ).pipe( - Effect.catch((cause) => - emit({ - ...createBaseEvent({ - threadId: input.threadId, - }), - type: "runtime.warning", - payload: { - message: "Failed to synchronize Copilot mode with the requested runtime mode.", - detail: cause, - }, - }), - ), - ); - - for (const event of earlyEvents) { - enqueueSdkEvent(context, event); - } - yield* Effect.promise(() => context.eventChain).pipe( - Effect.mapError((cause) => - processError( - input.threadId, - detailFromCause(cause, "Failed to process Copilot startup events."), - cause, - ), - ), - ); - updateProviderSession(context, { - status: context.session.status === "connecting" ? "ready" : context.session.status, - }); - - return context.session; - }, - ); - - const sendTurn: CopilotAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* requireSessionContext(sessions, input.threadId); - - const text = input.input?.trim(); - const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { - const filePath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!filePath) { - return Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.send", - detail: `Invalid attachment id '${attachment.id}'.`, - }), - ); - } - return Effect.succeed({ - type: "file" as const, - path: filePath, - displayName: attachment.name, }); + } + appendTurnItem(context, turnId, { + type: "assistant_message", + messageId: event.data.messageId, + content: event.data.content, }); - if ((!text || text.length === 0) && attachments.length === 0) { - return yield* validationError( - "sendTurn", - "Copilot turns require text input or at least one attachment.", - ); + return; + } + case "assistant.turn_end": { + const turnId = context.sdkTurnIdsToTurnIds.get(event.data.turnId) ?? context.activeTurnId; + if (!turnId) { + return; } - - const turnId = TurnId.make(`copilot-turn-${yield* Random.nextUUIDv4}`); - const modelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const reasoningEffort = getModelSelectionStringOptionValue( - modelSelection, - "reasoningEffort", - ) as CopilotReasoningEffort | undefined; - if (modelSelection?.model) { - yield* copilotSdk.setModel( - context, - modelSelection.model, - reasoningEffort, - ); - updateProviderSession(context, { - model: modelSelection.model, - ...(reasoningEffort ? { status: "ready" } : {}), - }); + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitTurnCompleted(context, turnId, "completed", { + raw: event, + stopReason: null, + }); + if (context.activeSdkTurnId === event.data.turnId) { + context.activeSdkTurnId = undefined; } - - const mode = requestedCopilotMode({ - runtimeMode: context.session.runtimeMode, - interactionMode: input.interactionMode, + return; + } + case "assistant.usage": { + const turnId = resolveTurnIdForEvent(context, { + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, }); - yield* syncSessionMode(context, mode); - - ensureTurnSnapshot(context, turnId); - context.queuedTurnIds.push(turnId); - context.activeTurnId = turnId; - updateProviderSession(context, { - status: "running", - activeTurnId: turnId, - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + 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, + }, }); - - yield* emit({ + return; + } + case "abort": { + const turnId = context.activeTurnId; + if (!turnId) { + return; + } + await emitAsync({ ...createBaseEvent({ - threadId: input.threadId, + threadId: context.threadId, turnId, + raw: event, }), - type: "turn.started", + type: "turn.aborted", payload: { - model: modelSelection?.model ?? context.session.model, - ...(reasoningEffort ? { effort: reasoningEffort } : {}), + reason: event.data.reason, }, }); - - const messageOptions: MessageOptions = { - prompt: text ?? "", - ...(attachments.length > 0 ? { attachments } : {}), - mode: "enqueue", - }; - - yield* copilotSdk.send(context, messageOptions).pipe( - Effect.catch((error) => - Effect.gen(function* () { - const queueIndex = context.queuedTurnIds.indexOf(turnId); - if (queueIndex >= 0) { - context.queuedTurnIds.splice(queueIndex, 1); - } - context.activeTurnId = undefined; - updateProviderSession(context, { - status: "ready", - activeTurnId: undefined, - }); - yield* emit({ - ...createBaseEvent({ - threadId: input.threadId, - turnId, - }), - type: "turn.aborted", - payload: { - reason: error.detail, - }, - }); - yield* Effect.tryPromise({ - try: () => - emitTurnCompleted(context, turnId, "failed", { - errorMessage: error.detail, - }), - catch: (cause) => - processError( - input.threadId, - detailFromCause(cause, "Failed to emit Copilot turn completion."), - cause, - ), - }); - return yield* error; - }), - ), + await emitTurnCompleted(context, turnId, "cancelled", { + raw: event, + stopReason: "aborted", + }); + return; + } + case "tool.execution_start": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const itemType = toolItemType(event.data.toolName, event.data.mcpServerName); + context.toolMetaById.set(event.data.toolCallId, { + toolName: event.data.toolName, + itemType, + }); + context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); + context.startedItemIds.add(itemId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.started", + payload: { + itemType, + status: "inProgress", + title: event.data.toolName, + ...(event.data.arguments ? { data: event.data.arguments } : {}), + }, + }); + return; + } + case "tool.execution_partial_result": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "content.delta", + payload: { + streamKind: toolStreamKind(toolMeta?.itemType), + delta: event.data.partialOutput, + }, + }); + return; + } + case "tool.execution_progress": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + const 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: event.data.progressMessage.trim(), + }, + }); + return; + } + case "tool.execution_complete": { + const turnId = resolveTurnIdForEvent(context, { + providerItemId: event.data.toolCallId, + parentProviderItemId: event.data.parentToolCallId, + sdkTurnId: context.activeSdkTurnId, + }); + if (!turnId) { + return; + } + const itemId = `copilot-tool-${event.data.toolCallId}`; + const toolMeta = context.toolMetaById.get(event.data.toolCallId); + const detail = + trimOrUndefined(event.data.result?.detailedContent) ?? + trimOrUndefined(event.data.result?.content) ?? + trimOrUndefined(event.data.error?.message); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId, + raw: event, + }), + type: "item.completed", + payload: { + itemType: toolMeta?.itemType ?? "dynamic_tool_call", + status: event.data.success ? "completed" : "failed", + title: toolMeta?.toolName ?? "tool", + ...(detail ? { detail } : {}), + ...(event.data.toolTelemetry || event.data.result || event.data.error + ? { + data: { + ...(event.data.result ? { result: event.data.result } : {}), + ...(event.data.error ? { error: event.data.error } : {}), + ...(event.data.toolTelemetry + ? { toolTelemetry: event.data.toolTelemetry } + : {}), + }, + } + : {}), + }, + }); + appendTurnItem(context, turnId, { + type: "tool_execution", + toolCallId: event.data.toolCallId, + toolName: toolMeta?.toolName, + success: event.data.success, + detail, + }); + if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + } + return; + } + case "permission.requested": { + if (event.data.resolvedByHook === true) { + return; + } + const signature = permissionSignature( + event.data.permissionRequest as SessionPermissionRequest & { kind: string }, ); + const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; + queue.push(event.data); + context.pendingPermissionEventsBySignature.set(signature, queue); + await runWithContext(bindPermissionRequests(context, signature)); + return; + } + case "permission.completed": { + const binding = context.pendingPermissionBindings.get(event.data.requestId); + if (!binding) { + return; + } + context.pendingPermissionBindings.delete(event.data.requestId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: binding.requestId, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: binding.requestType, + decision: event.data.result.kind, + resolution: event.data.result, + }, + }); + 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: { + answer: event.data.answer ?? "", + wasFreeform: event.data.wasFreeform ?? true, + }, + }, + }); + return; + } + case "exit_plan_mode.requested": { + await runWithContext(emitPlanSnapshot(context, event, event.data.planContent)); + return; + } + case "exit_plan_mode.completed": + default: + return; + } + }; - return { - threadId: input.threadId, - turnId, - resumeCursor: context.session.resumeCursor, - }; - }); + const startSession: CopilotAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* validationError( + "startSession", + `Expected provider '${PROVIDER}', received '${input.provider}'.`, + ); + } + if (input.providerInstanceId !== undefined && input.providerInstanceId !== boundInstanceId) { + return yield* validationError( + "startSession", + `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, + ); + } - const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { - const context = yield* requireSessionContext(sessions, threadId); + if (sessions.has(input.threadId)) { + yield* stopSessionInternal(input.threadId); + } - yield* copilotSdk.abort(context); + if (!settings.enabled) { + return yield* validationError("startSession", "Copilot is disabled in server settings."); + } - const targetTurnId = turnId ?? context.activeTurnId; - if (targetTurnId) { - yield* emit({ - ...createBaseEvent({ - threadId, - turnId: targetTurnId, - }), - type: "turn.aborted", - payload: { - reason: "Interrupted by user.", - }, - }); - } - }, - ); + 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; + 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( + input.runtimeMode === "approval-required" + ? DENIED_PERMISSION_RESULT + : APPROVED_PERMISSION_RESULT, + ), + ); + }; + const onSessionUserInputRequest = (_request: CopilotUserInputRequest) => { + return runWithContext( + context + ? onUserInputRequest(context, _request) + : Effect.succeed(EMPTY_USER_INPUT_RESPONSE), + ); + }; - const respondToRequest: CopilotAdapterShape["respondToRequest"] = Effect.fn( - "respondToRequest", - )(function* (threadId, requestId, decision) { - const context = yield* requireSessionContext(sessions, threadId); + const client = createCopilotClient({ + settings, + cwd, + ...(options?.environment ? { env: options.environment } : {}), + logLevel: "error", + }); - const binding = context.pendingPermissionBindings.get(requestId); - if (!binding) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "permission.reply", - detail: `Unknown pending permission request: ${requestId}`, + const baseSessionConfig = { + clientName: "t3-code", + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + workingDirectory: cwd, + streaming: true, + enableConfigDiscovery: true, + onEvent, + } satisfies Pick< + SessionConfig, + | "clientName" + | "model" + | "reasoningEffort" + | "workingDirectory" + | "streaming" + | "enableConfigDiscovery" + | "onEvent" + >; + + 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, }); } + return yield* copilotSdk.createSession(input.threadId, client, { + ...baseSessionConfig, + sessionId: input.threadId, + onPermissionRequest: onSessionPermissionRequest, + onUserInputRequest: onSessionUserInputRequest, + }); + }).pipe( + Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), + ); - const result: PermissionRequestResult = - decision === "accept" || decision === "acceptForSession" - ? { kind: "approved" } - : { kind: "denied-interactively-by-user" }; - yield* Deferred.succeed(binding.deferred, result); + context = { + threadId: input.threadId, + client, + sdkSession, + cwd, + session: { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + runtimeMode: input.runtimeMode, + cwd, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + threadId: input.threadId, + resumeCursor: toCopilotResumeCursor(sdkSession.sessionId), + createdAt: nowIso(), + updatedAt: nowIso(), + }, + turns: [], + queuedTurnIds: [], + sdkTurnIdsToTurnIds: new Map(), + completedTurnIds: new Set(), + turnUsageByTurnId: new Map(), + pendingPermissionHandlersBySignature: new Map(), + pendingPermissionEventsBySignature: new Map(), + pendingPermissionBindings: new Map(), + pendingUserInputHandlersBySignature: new Map(), + pendingUserInputEventsBySignature: new Map(), + pendingUserInputBindings: new Map(), + toolMetaById: new Map(), + turnIdByProviderItemId: new Map(), + emittedTextByItemId: new Map(), + pendingTaskCompletionTextByTurnId: new Map(), + turnIdsWithAssistantText: new Set(), + startedItemIds: new Set(), + activeTurnId: undefined, + activeSdkTurnId: undefined, + eventChain: Promise.resolve(), + stopped: false, + }; + sessions.set(input.threadId, context); + + yield* syncSessionMode( + context, + requestedCopilotMode({ + runtimeMode: input.runtimeMode, + }), + ).pipe( + Effect.catch((cause) => + emit({ + ...createBaseEvent({ + threadId: input.threadId, + }), + type: "runtime.warning", + payload: { + message: "Failed to synchronize Copilot mode with the requested runtime mode.", + detail: cause, + }, + }), + ), + ); + + for (const event of earlyEvents) { + enqueueSdkEvent(context, event); + } + yield* Effect.promise(() => context.eventChain).pipe( + Effect.mapError((cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to process Copilot startup events."), + cause, + ), + ), + ); + updateProviderSession(context, { + status: context.session.status === "connecting" ? "ready" : context.session.status, }); - const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = Effect.fn( - "respondToUserInput", - )(function* (threadId, requestId, answers) { - const context = yield* requireSessionContext(sessions, threadId); + return context.session; + }, + ); - const binding = context.pendingUserInputBindings.get(requestId); - if (!binding) { - return yield* new ProviderAdapterRequestError({ + 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: "user_input.reply", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: filePath, + displayName: attachment.name, + }); + }); + if ((!text || text.length === 0) && attachments.length === 0) { + return yield* validationError( + "sendTurn", + "Copilot turns require text input or at least one attachment.", + ); + } - const response = answerFromUserInput(binding, answers); - yield* Deferred.succeed(binding.deferred, response); + const turnId = TurnId.make(`copilot-turn-${yield* Random.nextUUIDv4}`); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") as + | CopilotReasoningEffort + | undefined; + if (modelSelection?.model) { + yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort); + updateProviderSession(context, { + model: modelSelection.model, + ...(reasoningEffort ? { status: "ready" } : {}), }); + } - const stopSessionInternal = (threadId: ThreadId): Effect.Effect => + const mode = requestedCopilotMode({ + runtimeMode: context.session.runtimeMode, + interactionMode: input.interactionMode, + }); + yield* syncSessionMode(context, mode); + + ensureTurnSnapshot(context, turnId); + context.queuedTurnIds.push(turnId); + context.activeTurnId = turnId; + updateProviderSession(context, { + status: "running", + 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 } : {}), + }, + }); + + const messageOptions: MessageOptions = { + prompt: text ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }; + + yield* copilotSdk.send(context, messageOptions).pipe( + Effect.catch((error) => Effect.gen(function* () { - const context = sessions.get(threadId); - if (!context) { - return; - } - if (context.stopped) { - sessions.delete(threadId); - return; + const queueIndex = context.queuedTurnIds.indexOf(turnId); + if (queueIndex >= 0) { + context.queuedTurnIds.splice(queueIndex, 1); } - - context.stopped = true; - yield* settlePendingPermissionHandlers(context); - yield* settlePendingUserInputs(context); - yield* copilotSdk.disconnect(context).pipe(Effect.ignore); - yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); - + context.activeTurnId = undefined; updateProviderSession(context, { - status: "closed", + status: "ready", activeTurnId: undefined, }); yield* emit({ ...createBaseEvent({ - threadId, + threadId: input.threadId, + turnId, }), - type: "session.state.changed", + type: "turn.aborted", payload: { - state: "stopped", - reason: "Copilot session stopped.", + reason: error.detail, }, }); - yield* emit({ - ...createBaseEvent({ - threadId, - }), - type: "session.exited", - payload: { - reason: "Copilot session stopped.", - exitKind: "graceful", - }, + yield* Effect.tryPromise({ + try: () => + emitTurnCompleted(context, turnId, "failed", { + errorMessage: error.detail, + }), + catch: (cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to emit Copilot turn completion."), + cause, + ), }); - sessions.delete(threadId); - }); - - const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( - function* (threadId) { - if (!sessions.has(threadId)) { - return yield* sessionNotFoundError(threadId); - } - yield* stopSessionInternal(threadId); - }, - ); + return yield* error; + }), + ), + ); + + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; + }); - const listSessions: CopilotAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (context) => context.session)); + const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = yield* requireSessionContext(sessions, threadId); - const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => sessions.has(threadId)); + yield* copilotSdk.abort(context); - const readThread: CopilotAdapterShape["readThread"] = Effect.fn("readThread")( - function* (threadId) { - const context = yield* requireSessionContext(sessions, threadId); - return { + const targetTurnId = turnId ?? context.activeTurnId; + if (targetTurnId) { + yield* emit({ + ...createBaseEvent({ threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), - }; - }, - ); + turnId: targetTurnId, + }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); - const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, _numTurns) { - if (!sessions.has(threadId)) { - return yield* sessionNotFoundError(threadId); - } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread.rollback", - detail: "Copilot SDK does not expose thread rollback.", - }); - }, - ); + const respondToRequest: CopilotAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( + function* (threadId, requestId, decision) { + const context = yield* requireSessionContext(sessions, threadId); - const stopAll: CopilotAdapterShape["stopAll"] = () => - Effect.gen(function* () { - yield* Effect.forEach(Array.from(sessions.keys()), stopSessionInternal, { - concurrency: "unbounded", - discard: true, - }); - if (managedNativeEventLogger) { - yield* managedNativeEventLogger.close(); - } + const binding = context.pendingPermissionBindings.get(requestId); + if (!binding) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, }); + } - return { + const result: PermissionRequestResult = + decision === "accept" || decision === "acceptForSession" + ? { kind: "approved" } + : { kind: "denied-interactively-by-user" }; + 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, - capabilities: { - sessionModelSwitch: "in-session", + method: "user_input.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + const response = answerFromUserInput(binding, answers); + yield* Deferred.succeed(binding.deferred, response); + }); + + const stopSessionInternal = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return; + } + if (context.stopped) { + sessions.delete(threadId); + return; + } + + context.stopped = true; + yield* settlePendingPermissionHandlers(context); + yield* settlePendingUserInputs(context); + yield* copilotSdk.disconnect(context).pipe(Effect.ignore); + yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + + updateProviderSession(context, { + status: "closed", + activeTurnId: undefined, + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.state.changed", + payload: { + state: "stopped", + reason: "Copilot session stopped.", }, - startSession, - sendTurn, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - readThread, - rollbackThread, - stopAll, - get streamEvents() { - return Stream.fromPubSub(runtimeEventPubSub); + }); + yield* emit({ + ...createBaseEvent({ + threadId, + }), + type: "session.exited", + payload: { + reason: "Copilot session stopped.", + exitKind: "graceful", }, - } satisfies CopilotAdapterShape; + }); + sessions.delete(threadId); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + yield* stopSessionInternal(threadId); + }, + ); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (context) => context.session)); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = yield* requireSessionContext(sessions, threadId); + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }, + ); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, _numTurns) { + if (!sessions.has(threadId)) { + return yield* sessionNotFoundError(threadId); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: "Copilot SDK does not expose thread rollback.", + }); + }, + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.gen(function* () { + yield* Effect.forEach(Array.from(sessions.keys()), stopSessionInternal, { + concurrency: "unbounded", + discard: true, + }); + if (managedNativeEventLogger) { + yield* managedNativeEventLogger.close(); + } + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromPubSub(runtimeEventPubSub); + }, + } satisfies CopilotAdapterShape; }); export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { From 14dc9ce48dba4bdf02667728f224992131490b93 Mon Sep 17 00:00:00 2001 From: Zortos Date: Fri, 8 May 2026 09:07:37 +0200 Subject: [PATCH 011/104] Remove unused Copilot provider service --- apps/server/src/provider/Services/CopilotProvider.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 apps/server/src/provider/Services/CopilotProvider.ts diff --git a/apps/server/src/provider/Services/CopilotProvider.ts b/apps/server/src/provider/Services/CopilotProvider.ts deleted file mode 100644 index 2dddc77a128..00000000000 --- a/apps/server/src/provider/Services/CopilotProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider.ts"; - -export interface CopilotProviderShape extends ServerProviderShape {} - -export class CopilotProvider extends Context.Service()( - "t3/provider/Services/CopilotProvider", -) {} From 22c24310b455106a452de84da5a9845245914caa Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 11 May 2026 21:25:59 +0200 Subject: [PATCH 012/104] Handle empty Copilot tool progress messages --- .../provider/Layers/CopilotAdapter.test.ts | 79 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 6 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 8a95d7eb9be..1d9883441b9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -304,6 +304,85 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("ignores empty SDK tool progress messages without failing the session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-sdk-event-queue-recovers-after-handler-failure"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "finish even after a bad tool progress event", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-turn-start-after-bad-event", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-bad-event", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-bad-progress", + timestamp, + parentId: null, + type: "tool.execution_progress", + data: { + toolCallId: "tool-progress-bad", + progressMessage: null, + }, + } as unknown as SessionEvent); + emit({ + id: "evt-copilot-turn-end-after-bad-event", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-bad-event", + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find((event) => event.type === "turn.completed"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + const toolProgress = runtimeEvents.find((event) => event.type === "tool.progress"); + assert.equal(runtimeError, undefined); + assert.equal(toolProgress, undefined); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); it.effect("completes the turn as failed when Copilot send rejects", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 0ca3ddb0993..1270cc90ca2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1744,6 +1744,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( providerItemId: event.data.toolCallId, sdkTurnId: context.activeSdkTurnId, }); + const summary = trimOrUndefined(event.data.progressMessage); + if (!turnId || !summary) { + return; + } const toolMeta = context.toolMetaById.get(event.data.toolCallId); await emitAsync({ ...createBaseEvent({ @@ -1756,7 +1760,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( payload: { toolUseId: event.data.toolCallId, ...(toolMeta ? { toolName: toolMeta.toolName } : {}), - summary: event.data.progressMessage.trim(), + summary, }, }); return; From b5cfb9f96d492df44918b39b03f7c144bd2e6964 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 16 May 2026 17:53:00 +0200 Subject: [PATCH 013/104] Emit Copilot fallback message for tool-only turns --- .../provider/Layers/CopilotAdapter.test.ts | 104 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 96 +++++++++++++++- 2 files changed, 196 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 1d9883441b9..91169c3ee04 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -304,6 +304,110 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("renders a fallback assistant message when a tool-only turn completes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-tool-only-assistant-fallback"); + + 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 config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-tool-only-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-tool-only", + }, + } 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-tool-only-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-tool-only", + }, + } as SessionEvent); + + let thread = yield* adapter.readThread(threadId); + for ( + let attempt = 0; + attempt < 20 && + !thread.turns.some((entry) => + entry.items.some( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + thread = yield* adapter.readThread(threadId); + } + + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItem = turnSnapshot.items.find( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItem, { + type: "assistant_message", + messageId: `copilot-tool-completion-${String(turn.turnId)}`, + content: "Done. I completed the requested file changes.", + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores empty SDK tool progress messages without failing the session", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 1270cc90ca2..f8368252e5e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -105,6 +105,15 @@ interface ToolMeta { | "image_view"; } +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; @@ -223,6 +232,47 @@ function appendTurnItem( ensureTurnSnapshot(context, turnId).items.push(item); } +function isCopilotToolExecutionItem(item: unknown): item is CopilotToolExecutionItem { + return ( + Predicate.hasProperty(item, "type") && + item.type === "tool_execution" && + Predicate.hasProperty(item, "toolCallId") && + Predicate.isString(item.toolCallId) && + Predicate.hasProperty(item, "success") && + typeof item.success === "boolean" + ); +} + +function toolOnlyCompletionText( + context: CopilotSessionContext, + turnId: TurnId, +): string | undefined { + if (context.turnIdsWithAssistantText.has(turnId)) { + return undefined; + } + + const turn = context.turns.find((entry) => entry.id === turnId); + const completedTools = + turn?.items.filter( + (item): item is CopilotToolExecutionItem => isCopilotToolExecutionItem(item) && item.success, + ) ?? []; + if (completedTools.length === 0) { + return undefined; + } + + const itemTypes = new Set(completedTools.map((item) => item.itemType).filter(Boolean)); + if (itemTypes.has("file_change")) { + return "Done. I completed the requested file changes."; + } + if (itemTypes.has("command_execution")) { + return "Done. I ran the requested commands."; + } + if (itemTypes.has("collab_agent_tool_call")) { + return "Done. I completed the requested task."; + } + return "Done. I completed the requested tool work."; +} + function processError( threadId: ThreadId, detail: string, @@ -944,6 +994,40 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( messageId: itemId, content, }); + context.turnIdsWithAssistantText.add(turnId); + }; + + const emitToolOnlyCompletionAsAssistantMessage = async ( + context: CopilotSessionContext, + turnId: TurnId, + raw: SessionEvent, + ) => { + const content = toolOnlyCompletionText(context, turnId); + if (!content) { + return; + } + + const itemId = `copilot-tool-completion-${String(turnId)}`; + 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.turnIdsWithAssistantText.add(turnId); }; const emitPermissionRequestOpened = ( @@ -1341,6 +1425,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (context.activeTurnId) { if (!event.data.aborted) { await emitPendingTaskCompletionAsAssistantMessage(context, context.activeTurnId, event); + await emitToolOnlyCompletionAsAssistantMessage(context, context.activeTurnId, event); } await emitTurnCompleted( context, @@ -1626,6 +1711,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, stopReason: null, @@ -1806,13 +1892,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : {}), }, }); - appendTurnItem(context, turnId, { + const toolItem: CopilotToolExecutionItem = { type: "tool_execution", toolCallId: event.data.toolCallId, - toolName: toolMeta?.toolName, + ...(toolMeta?.toolName ? { toolName: toolMeta.toolName } : {}), + ...(toolMeta?.itemType ? { itemType: toolMeta.itemType } : {}), success: event.data.success, - detail, - }); + ...(detail ? { detail } : {}), + }; + appendTurnItem(context, turnId, toolItem); if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { context.pendingTaskCompletionTextByTurnId.set(turnId, detail); } From 06a39095ce5e3e0c9bef0754fb5e94cc8716a152 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 11:48:05 +0200 Subject: [PATCH 014/104] Fix Copilot integration after main rebase --- .../server/src/provider/Drivers/CopilotDriver.ts | 3 ++- .../src/provider/Layers/CopilotAdapter.test.ts | 16 +++++++++------- .../server/src/provider/Layers/CopilotAdapter.ts | 16 ++++++++++------ .../src/provider/Layers/CopilotProvider.ts | 6 +++--- .../src/textGeneration/CopilotTextGeneration.ts | 2 +- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index 8817865255e..dedfcf2b02c 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -82,7 +82,8 @@ export const CopilotDriver: ProviderDriver = getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, - initialSnapshot: (settings) => stampIdentity(makePendingCopilotProvider(settings)), + initialSnapshot: (settings) => + Effect.succeed(stampIdentity(makePendingCopilotProvider(settings))), checkProvider: checkCopilotProviderStatus({ settings: effectiveConfig, cwd: serverConfig.cwd, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 91169c3ee04..9bb883a9813 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { setTimeout as sleep } from "node:timers/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -9,7 +10,7 @@ import type { SessionEvent, } from "@github/copilot-sdk"; import { it } from "@effect/vitest"; -import { Effect, Fiber, Layer, Stream } from "effect"; +import { DateTime, Effect, Fiber, Layer, Stream } from "effect"; import { beforeEach, vi } from "vitest"; import { type ProviderRuntimeEvent, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; @@ -21,8 +22,8 @@ import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const COPILOT_DRIVER = ProviderDriverKind.make("copilot"); -const waitForSdkEventQueue = () => - Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const waitForSdkEventQueue = () => Effect.promise(() => sleep(10).then(() => undefined)); const runtimeMock = vi.hoisted(() => { const makeSession = () => ({ @@ -222,10 +223,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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: new Date().toISOString(), + timestamp, parentId: null, type: "assistant.turn_start", data: { @@ -234,7 +236,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } as SessionEvent); emit({ id: "evt-copilot-task-start", - timestamp: new Date().toISOString(), + timestamp, parentId: null, type: "tool.execution_start", data: { @@ -245,7 +247,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } as SessionEvent); emit({ id: "evt-copilot-task-complete", - timestamp: new Date().toISOString(), + timestamp, parentId: null, type: "tool.execution_complete", data: { @@ -258,7 +260,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } as SessionEvent); emit({ id: "evt-copilot-idle", - timestamp: new Date().toISOString(), + timestamp, parentId: null, type: "session.idle", data: { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index f8368252e5e..fcd56745661 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import type { CopilotClient, CopilotSession, @@ -25,7 +27,7 @@ import { type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { Deferred, Effect, Layer, Path, Predicate, PubSub, Random, Stream } from "effect"; +import { DateTime, Deferred, Effect, Layer, Path, Predicate, PubSub, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -159,7 +161,7 @@ const EMPTY_USER_INPUT_RESPONSE = { } satisfies CopilotUserInputResponse; function nowIso(): string { - return new Date().toISOString(); + return DateTime.formatIso(DateTime.nowUnsafe()); } function parseCopilotResumeCursor(raw: unknown): { sessionId: string } | undefined { @@ -192,7 +194,7 @@ function createBaseEvent(input: { readonly raw?: SessionEvent | undefined; }) { return { - eventId: EventId.make(Effect.runSync(Random.nextUUIDv4)), + eventId: EventId.make(randomUUID()), provider: PROVIDER, threadId: input.threadId, createdAt: input.createdAt ?? nowIso(), @@ -643,7 +645,7 @@ function resolveTurnIdForSdkTurn(context: CopilotSessionContext, sdkTurnId: stri context.queuedTurnIds.shift() ?? context.activeTurnId ?? latestTurnId(context) ?? - TurnId.make(`copilot-turn-${Effect.runSync(Random.nextUUIDv4)}`); + TurnId.make(`copilot-turn-${randomUUID()}`); context.sdkTurnIdsToTurnIds.set(sdkTurnId, nextTurnId); ensureTurnSnapshot(context, nextTurnId); context.activeSdkTurnId = sdkTurnId; @@ -2202,7 +2204,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); } - const turnId = TurnId.make(`copilot-turn-${yield* Random.nextUUIDv4}`); + const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") as @@ -2291,7 +2293,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return { threadId: input.threadId, turnId, - resumeCursor: context.session.resumeCursor, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), }; }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 76a0f104bd1..19625924b5f 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; -import { Effect } from "effect"; +import { DateTime, Effect } from "effect"; import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { @@ -18,7 +18,7 @@ const COPILOT_PRESENTATION = { } as const; export function makePendingCopilotProvider(settings: CopilotSettings): ServerProviderDraft { - const checkedAt = new Date().toISOString(); + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); const models = modelsFromCopilotSdk({ models: [], customModels: settings.customModels, @@ -66,7 +66,7 @@ export function checkCopilotProviderStatus(input: { return Effect.succeed(makePendingCopilotProvider(input.settings)); } - const checkedAt = new Date().toISOString(); + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); const fallback = (cause: unknown, version: string | null = null) => { const failure = formatCopilotProbeError({ cause, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 4f0c8c38362..8a5f4e8c64e 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -8,6 +8,7 @@ import { type ModelSelection, TextGenerationError, } from "@t3tools/contracts"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -22,7 +23,6 @@ import { } from "./TextGenerationPrompts.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { - extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, From 40e93a2c287d8e76424017bcfdfed00d076ceaca Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 12:14:26 +0200 Subject: [PATCH 015/104] Handle invalid Copilot CLI paths safely --- .../src/provider/Layers/CopilotAdapter.ts | 19 +++-- .../provider/Layers/CopilotProvider.test.ts | 72 +++++++++++++------ .../src/provider/Layers/CopilotProvider.ts | 20 +++--- .../src/provider/copilotRuntime.test.ts | 6 +- apps/server/src/provider/copilotRuntime.ts | 36 +++++++++- .../textGeneration/CopilotTextGeneration.ts | 19 +++-- 6 files changed, 128 insertions(+), 44 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index fcd56745661..c921eb2c6e1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2046,11 +2046,20 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); }; - const client = createCopilotClient({ - settings, - cwd, - ...(options?.environment ? { env: options.environment } : {}), - logLevel: "error", + const client = yield* Effect.try({ + try: () => + createCopilotClient({ + settings, + cwd, + ...(options?.environment ? { env: options.environment } : {}), + logLevel: "error", + }), + catch: (cause) => + processError( + input.threadId, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), }); const baseSessionConfig = { diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 798e98e5c91..2532a8f8df4 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -10,12 +10,14 @@ 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; }, }; }); @@ -26,27 +28,32 @@ vi.mock("../copilotRuntime.ts", async () => { return { ...actual, - createCopilotClient: vi.fn(() => ({ - 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 []; - }), - })), + createCopilotClient: vi.fn(() => { + if (runtimeMock.state.createClientError) { + throw runtimeMock.state.createClientError; + } + return { + 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 []; + }), + }; + }), }; }); @@ -71,4 +78,27 @@ describe("CopilotProvider status", () => { assert.equal(snapshot.message, "401 Unauthorized"); }), ); + + it.effect("returns an error snapshot when the configured Copilot CLI path is invalid", () => + Effect.gen(function* () { + runtimeMock.state.createClientError = new Error( + "The configured Copilot binary could not be found: /missing/copilot.", + ); + + const snapshot = yield* checkCopilotProviderStatus({ + settings: { + ...defaultCopilotSettings, + binaryPath: "/missing/copilot", + }, + cwd: process.cwd(), + }); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal( + snapshot.message, + "The configured Copilot binary could not be started: /missing/copilot.", + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 19625924b5f..d0d4f35398e 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -92,14 +92,16 @@ export function checkCopilotProviderStatus(input: { }; return Effect.acquireUseRelease( - Effect.sync(() => - createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - ...(input.environment ? { env: input.environment } : {}), - logLevel: "error", - }), - ), + Effect.try({ + try: () => + createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(input.environment ? { env: input.environment } : {}), + logLevel: "error", + }), + catch: toCopilotProbeError, + }), (client) => Effect.tryPromise({ try: async () => { @@ -143,5 +145,5 @@ export function checkCopilotProviderStatus(input: { 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/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 23b257fd170..43067b8af17 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -30,10 +30,12 @@ describe("buildCopilotClientOptions", () => { }); it("prefers the configured binary path over any inherited CLI path override", () => { + const configuredBinaryPath = process.execPath; + const options = buildCopilotClientOptions({ settings: { enabled: true, - binaryPath: "/custom/copilot", + binaryPath: configuredBinaryPath, serverUrl: "", customModels: [], }, @@ -42,7 +44,7 @@ describe("buildCopilotClientOptions", () => { }, }); - assert.equal(options.cliPath, "/custom/copilot"); + assert.equal(options.cliPath, configuredBinaryPath); assert.equal(options.env?.COPILOT_CLI_PATH, undefined); }); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 757c5449338..b999191cec4 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -14,6 +14,7 @@ import type { } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; @@ -104,6 +105,32 @@ export function createCopilotClient(input: { return new CopilotClient(buildCopilotClientOptions(input)); } +function validateConfiguredCopilotCliPath(input: { + readonly settings: CopilotSettings; + readonly env?: Record; +}): string | undefined { + const cliUrl = trimOrUndefined(input.settings.serverUrl); + if (cliUrl) { + return undefined; + } + + const cliPath = trimOrUndefined(input.settings.binaryPath); + if (!cliPath) { + return undefined; + } + + const env = { + ...process.env, + ...(input.env ?? {}), + }; + const resolvedCommandPath = resolveCommandPath(cliPath, { env }); + if (!resolvedCommandPath) { + throw new Error(`The configured Copilot binary could not be found: ${cliPath}.`); + } + + return resolvedCommandPath; +} + export function buildCopilotClientOptions(input: { readonly settings: CopilotSettings; readonly cwd?: string; @@ -111,7 +138,10 @@ export function buildCopilotClientOptions(input: { readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; }): CopilotClientOptions { - const cliPath = trimOrUndefined(input.settings.binaryPath); + const cliPath = validateConfiguredCopilotCliPath({ + settings: input.settings, + ...(input.env ? { env: input.env } : {}), + }); const cliUrl = trimOrUndefined(input.settings.serverUrl); const env = { ...process.env }; @@ -256,7 +286,9 @@ export function formatCopilotProbeError(input: { lower.includes("enoent") || lower.includes("spawn") || lower.includes("not found") || - lower.includes("could not find") + lower.includes("could not find") || + lower.includes("could not be found") || + lower.includes("not executable") ) { return { installed: false, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 8a5f4e8c64e..a4b857bddb0 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -177,11 +177,20 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( return existing.client; } - const client = createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - env: environment, - logLevel: "error", + const client = yield* Effect.try({ + try: () => + createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + env: environment, + logLevel: "error", + }), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), }); yield* Effect.tryPromise({ try: () => client.start(), From 10e6df7a26038989f5682e50b6df02bc6823fe47 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 12:56:15 +0200 Subject: [PATCH 016/104] Refine Copilot provider auth labels --- .../src/provider/copilotRuntime.test.ts | 16 ++++- apps/server/src/provider/copilotRuntime.ts | 2 +- .../settings/ProviderInstanceCard.tsx | 63 ++++++++++++++++--- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 43067b8af17..85bfe2b3369 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; -import { buildCopilotClientOptions } from "./copilotRuntime.ts"; +import { authSnapshotFromCopilotSdk, buildCopilotClientOptions } from "./copilotRuntime.ts"; describe("buildCopilotClientOptions", () => { it("strips inherited COPILOT_CLI_PATH so the SDK uses the bundled CLI by default", () => { @@ -47,4 +47,18 @@ describe("buildCopilotClientOptions", () => { assert.equal(options.cliPath, configuredBinaryPath); assert.equal(options.env?.COPILOT_CLI_PATH, undefined); }); + + it("omits the generic signed-in user prefix from authenticated Copilot labels", () => { + const snapshot = authSnapshotFromCopilotSdk({ + isAuthenticated: true, + authType: "user", + host: "https://github.com", + statusMessage: "octocat", + login: "octocat", + }); + + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.auth.type, "user"); + assert.equal(snapshot.auth.label, "@octocat - github.com"); + }); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index b999191cec4..64fd44a1faa 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -79,7 +79,7 @@ function describeCopilotProbeCause(cause: unknown): string { function authTypeLabel(authType: GetAuthStatusResponse["authType"]): string | undefined { switch (authType) { case "user": - return "Signed-in user"; + return undefined; case "env": return "Environment token"; case "gh-cli": diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ac2f7be81e8..a9086b026d6 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -153,6 +153,48 @@ 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); + + return ( + + {props.separator ? · : null} + {parts.map((part, index) => ( + + {index > 0 ? - : null} + {part.startsWith("@") ? ( + + ) : ( + {part} + )} + + ))} + + ); + } + + return props.type ? ( + + {props.separator ? · : null} + {props.type} + + ) : null; +} + function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -401,14 +443,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 +620,13 @@ export function ProviderInstanceCard({ const authRowNode = (

- {hasAuthenticatedEmail ? ( + {isAuthenticated ? ( <> - Authenticated as + + {hasAuthenticatedEmail || hasAuthenticatedLabel ? "Authenticated as" : "Authenticated"} + - {authenticatedDetail ? · {authenticatedDetail} : null} + ) : ( <> From a7dcf3e71f40287ab867002265cd6b7195d8de5b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 1 Jun 2026 10:53:47 +0200 Subject: [PATCH 017/104] Preserve Copilot auth status labels --- .../src/provider/copilotRuntime.test.ts | 14 +++++++++++++ apps/server/src/provider/copilotRuntime.ts | 21 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 85bfe2b3369..d7848819161 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -61,4 +61,18 @@ describe("buildCopilotClientOptions", () => { assert.equal(snapshot.auth.type, "user"); assert.equal(snapshot.auth.label, "@octocat - github.com"); }); + + it("prefers the richer authenticated status message when it differs from the raw login", () => { + const snapshot = authSnapshotFromCopilotSdk({ + isAuthenticated: true, + authType: "gh-cli", + host: "https://github.com", + statusMessage: "zortos293 (via gh)", + login: "zortos293", + }); + + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.auth.type, "gh-cli"); + assert.equal(snapshot.auth.label, "zortos293 (via gh)"); + }); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 64fd44a1faa..17bf06d413e 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -95,6 +95,11 @@ function authTypeLabel(authType: GetAuthStatusResponse["authType"]): string | un } } +function normalizeAuthLabelPart(value: string | null | undefined): string | undefined { + const trimmed = trimOrUndefined(value); + return trimmed ? trimmed.replace(/^@/, "").toLowerCase() : undefined; +} + export function createCopilotClient(input: { readonly settings: CopilotSettings; readonly cwd?: string; @@ -217,13 +222,25 @@ export function authSnapshotFromCopilotSdk(authStatus: GetAuthStatusResponse): { readonly message?: string; } { const authType = trimOrUndefined(authStatus.authType); - const label = [ - authTypeLabel(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 { From fdbde1296c4a5ecf9b30fb482d82f28ff713edb3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 10:42:31 +0200 Subject: [PATCH 018/104] Adapt Copilot changes to latest main --- .../src/provider/Drivers/CopilotDriver.ts | 3 +- apps/server/src/provider/copilotRuntime.ts | 2 +- .../textGeneration/CopilotTextGeneration.ts | 5 +- .../settings/ProviderInstanceCard.tsx | 25 ++- pnpm-lock.yaml | 152 +++++++++++++++++- 5 files changed, 175 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index dedfcf2b02c..27c7bc4bf9b 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -22,6 +22,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte const DRIVER_KIND = ProviderDriverKind.make("copilot"); const SNAPSHOT_REFRESH_INTERVAL = Duration.hours(1); +const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); export type CopilotDriverEnv = Path.Path | ProviderEventLoggers | ServerConfig; @@ -48,7 +49,7 @@ export const CopilotDriver: ProviderDriver = supportsMultipleInstances: true, }, configSchema: CopilotSettings, - defaultConfig: (): CopilotSettings => Schema.decodeSync(CopilotSettings)({}), + defaultConfig: (): CopilotSettings => decodeCopilotSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const serverConfig = yield* ServerConfig; diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 17bf06d413e..be4352fecfd 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -126,7 +126,7 @@ function validateConfiguredCopilotCliPath(input: { const env = { ...process.env, - ...(input.env ?? {}), + ...input.env, }; const resolvedCommandPath = resolveCommandPath(cliPath, { env }); if (!resolvedCommandPath) { diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index a4b857bddb0..9f00d772641 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -339,9 +339,8 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }); } - return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( - extractJsonObject(rawContent), - ).pipe( + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawContent)).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a9086b026d6..65484eb595b 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -164,22 +164,35 @@ function ProviderAuthLabel(props: { .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} - {parts.map((part, index) => ( - - {index > 0 ? - : null} - {part.startsWith("@") ? ( + {keyedParts.map((part) => ( + + {part.separator ? - : null} + {part.part.startsWith("@") ? ( ) : ( - {part} + {part.part} )} ))} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 636e6950fe5..66643a019bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,8 +460,11 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@ff-labs/fff-node': - specifier: 0.9.4 + specifier: ^0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + '@github/copilot-sdk': + specifier: ^0.2.2 + version: 0.2.2(typescript@6.0.3) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2692,6 +2695,62 @@ packages: '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + '@github/copilot-darwin-arm64@1.0.63': + resolution: {integrity: sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@1.0.63': + resolution: {integrity: sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@1.0.63': + resolution: {integrity: sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@1.0.63': + resolution: {integrity: sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-linuxmusl-arm64@1.0.63': + resolution: {integrity: sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linuxmusl-x64@1.0.63': + resolution: {integrity: sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-sdk@0.2.2': + resolution: {integrity: sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==} + engines: {node: '>=20.0.0'} + + '@github/copilot-win32-arm64@1.0.63': + resolution: {integrity: sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@1.0.63': + resolution: {integrity: sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@1.0.63': + resolution: {integrity: sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==} + hasBin: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -3222,6 +3281,21 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + '@os-theme/darwin-arm64@0.0.8': + resolution: {integrity: sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==} + cpu: [arm64] + os: [darwin] + + '@os-theme/linux-x64@0.0.8': + resolution: {integrity: sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==} + cpu: [x64] + os: [linux] + + '@os-theme/win32-x64@0.0.8': + resolution: {integrity: sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==} + cpu: [x64] + os: [win32] + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -8355,6 +8429,11 @@ packages: resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} engines: {node: '>= 4.0'} + os-theme@0.0.8: + resolution: {integrity: sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==} + peerDependencies: + typescript: ^5 + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -10080,6 +10159,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==} @@ -12480,6 +12563,54 @@ snapshots: '@formkit/auto-animate@0.9.0': {} + '@github/copilot-darwin-arm64@1.0.63': + optional: true + + '@github/copilot-darwin-x64@1.0.63': + optional: true + + '@github/copilot-linux-arm64@1.0.63': + optional: true + + '@github/copilot-linux-x64@1.0.63': + optional: true + + '@github/copilot-linuxmusl-arm64@1.0.63': + optional: true + + '@github/copilot-linuxmusl-x64@1.0.63': + optional: true + + '@github/copilot-sdk@0.2.2(typescript@6.0.3)': + dependencies: + '@github/copilot': 1.0.63(typescript@6.0.3) + vscode-jsonrpc: 8.2.1 + zod: 4.4.3 + transitivePeerDependencies: + - typescript + + '@github/copilot-win32-arm64@1.0.63': + optional: true + + '@github/copilot-win32-x64@1.0.63': + optional: true + + '@github/copilot@1.0.63(typescript@6.0.3)': + dependencies: + detect-libc: 2.1.2 + os-theme: 0.0.8(typescript@6.0.3) + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.63 + '@github/copilot-darwin-x64': 1.0.63 + '@github/copilot-linux-arm64': 1.0.63 + '@github/copilot-linux-x64': 1.0.63 + '@github/copilot-linuxmusl-arm64': 1.0.63 + '@github/copilot-linuxmusl-x64': 1.0.63 + '@github/copilot-win32-arm64': 1.0.63 + '@github/copilot-win32-x64': 1.0.63 + transitivePeerDependencies: + - typescript + '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -13066,6 +13197,15 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@os-theme/darwin-arm64@0.0.8': + optional: true + + '@os-theme/linux-x64@0.0.8': + optional: true + + '@os-theme/win32-x64@0.0.8': + optional: true + '@oslojs/encoding@1.1.0': {} '@oxc-project/runtime@0.138.0': {} @@ -18490,6 +18630,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + os-theme@0.0.8(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + optionalDependencies: + '@os-theme/darwin-arm64': 0.0.8 + '@os-theme/linux-x64': 0.0.8 + '@os-theme/win32-x64': 0.0.8 + outvariant@1.4.3: {} oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): @@ -20408,6 +20556,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 From d3cf8ca5f021dde5ee62bf55f274ef84171cb20d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 11:32:00 +0200 Subject: [PATCH 019/104] Fix Copilot CLI launch in desktop --- apps/server/package.json | 1 + .../src/provider/copilotRuntime.test.ts | 19 ++- apps/server/src/provider/copilotRuntime.ts | 61 ++++++++- pnpm-lock.yaml | 121 ++++++------------ scripts/build-desktop-artifact.test.ts | 17 +++ 5 files changed, 132 insertions(+), 87 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 27a7fead350..8ac31679144 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@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": "^0.2.2", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index d7848819161..da180421a5a 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -2,10 +2,14 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; -import { authSnapshotFromCopilotSdk, buildCopilotClientOptions } from "./copilotRuntime.ts"; +import { + authSnapshotFromCopilotSdk, + buildCopilotClientOptions, + resolveBundledCopilotCliPath, +} from "./copilotRuntime.ts"; describe("buildCopilotClientOptions", () => { - it("strips inherited COPILOT_CLI_PATH so the SDK uses the bundled CLI by default", () => { + it("strips inherited COPILOT_CLI_PATH and uses the local Copilot CLI shim by default", () => { const options = buildCopilotClientOptions({ settings: { enabled: true, @@ -22,13 +26,22 @@ describe("buildCopilotClientOptions", () => { logLevel: "error", }); - assert.equal(options.cliPath, undefined); + assert.ok(options.cliPath?.includes("node_modules/.bin/copilot")); assert.equal(options.cwd, "/tmp/project"); assert.equal(options.logLevel, "error"); assert.equal(options.env?.COPILOT_CLI_PATH, undefined); assert.equal(options.env?.GITHUB_TOKEN, "github-token"); }); + it("resolves the bundled Copilot CLI shim without relying on PATH", () => { + const cliPath = resolveBundledCopilotCliPath({ + cwd: "/tmp/project", + env: { PATH: "/usr/bin" }, + }); + + assert.ok(cliPath?.includes("node_modules/.bin/copilot")); + }); + it("prefers the configured binary path over any inherited CLI path override", () => { const configuredBinaryPath = process.execPath; diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index be4352fecfd..52954a31266 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import { CopilotClient, type CopilotClientOptions, @@ -5,6 +6,8 @@ import { type GetStatusResponse, type ModelInfo, } from "@github/copilot-sdk"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { CopilotSettings, ModelCapabilities, @@ -36,6 +39,7 @@ const GENERIC_EFFECT_TRY_PROMISE_MESSAGES = new Set([ "An error occurred in Effect.try", ]); const COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH"; +const COPILOT_CLI_COMMAND = "copilot"; export class CopilotProbePromiseError extends Error { override readonly cause: unknown; @@ -136,6 +140,49 @@ function validateConfiguredCopilotCliPath(input: { return resolvedCommandPath; } +function candidateDirectoryAncestors(directory: string): ReadonlyArray { + const directories: string[] = []; + let current = directory; + + for (let depth = 0; depth < 8; depth += 1) { + directories.push(current); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return directories; +} + +export function resolveBundledCopilotCliPath(input: { + readonly cwd?: string; + readonly env?: Record; +}): string | undefined { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const candidateRoots = new Set(); + + if (input.cwd) { + candidateRoots.add(input.cwd); + } + candidateRoots.add(process.cwd()); + + for (const directory of candidateDirectoryAncestors(moduleDirectory)) { + candidateRoots.add(directory); + } + + for (const root of candidateRoots) { + const candidate = join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); + const resolved = resolveCommandPath(candidate, input.env ? { env: input.env } : {}); + if (resolved) { + return resolved; + } + } + + return undefined; +} + export function buildCopilotClientOptions(input: { readonly settings: CopilotSettings; readonly cwd?: string; @@ -143,10 +190,6 @@ export function buildCopilotClientOptions(input: { readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; }): CopilotClientOptions { - const cliPath = validateConfiguredCopilotCliPath({ - settings: input.settings, - ...(input.env ? { env: input.env } : {}), - }); const cliUrl = trimOrUndefined(input.settings.serverUrl); const env = { ...process.env }; @@ -156,6 +199,16 @@ export function buildCopilotClientOptions(input: { delete env[COPILOT_CLI_PATH_ENV]; + const configuredCliPath = validateConfiguredCopilotCliPath({ + settings: input.settings, + env, + }); + const bundledCliPath = + !cliUrl && !configuredCliPath + ? resolveBundledCopilotCliPath({ ...(input.cwd ? { cwd: input.cwd } : {}), env }) + : undefined; + const cliPath = configuredCliPath ?? bundledCliPath; + return { ...(cliUrl ? { cliUrl } : {}), ...(!cliUrl && cliPath ? { cliPath } : {}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66643a019bc..39d8c04c7b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,9 +462,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: ^0.2.2 - version: 0.2.2(typescript@6.0.3) + version: 0.2.2 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2695,38 +2698,38 @@ packages: '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} - '@github/copilot-darwin-arm64@1.0.63': - resolution: {integrity: sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==} + '@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.63': - resolution: {integrity: sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==} + '@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.63': - resolution: {integrity: sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==} + '@github/copilot-linux-arm64@1.0.60': + resolution: {integrity: sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.63': - resolution: {integrity: sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==} + '@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.63': - resolution: {integrity: sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==} + '@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.63': - resolution: {integrity: sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==} + '@github/copilot-linuxmusl-x64@1.0.60': + resolution: {integrity: sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==} cpu: [x64] os: [linux] hasBin: true @@ -2735,20 +2738,20 @@ packages: resolution: {integrity: sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==} engines: {node: '>=20.0.0'} - '@github/copilot-win32-arm64@1.0.63': - resolution: {integrity: sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==} + '@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.63': - resolution: {integrity: sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==} + '@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.63': - resolution: {integrity: sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==} + '@github/copilot@1.0.60': + resolution: {integrity: sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==} hasBin: true '@hono/node-server@1.19.14': @@ -3281,21 +3284,6 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} - '@os-theme/darwin-arm64@0.0.8': - resolution: {integrity: sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==} - cpu: [arm64] - os: [darwin] - - '@os-theme/linux-x64@0.0.8': - resolution: {integrity: sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==} - cpu: [x64] - os: [linux] - - '@os-theme/win32-x64@0.0.8': - resolution: {integrity: sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==} - cpu: [x64] - os: [win32] - '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -8429,11 +8417,6 @@ packages: resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} engines: {node: '>= 4.0'} - os-theme@0.0.8: - resolution: {integrity: sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==} - peerDependencies: - typescript: ^5 - outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -12563,53 +12546,48 @@ snapshots: '@formkit/auto-animate@0.9.0': {} - '@github/copilot-darwin-arm64@1.0.63': + '@github/copilot-darwin-arm64@1.0.60': optional: true - '@github/copilot-darwin-x64@1.0.63': + '@github/copilot-darwin-x64@1.0.60': optional: true - '@github/copilot-linux-arm64@1.0.63': + '@github/copilot-linux-arm64@1.0.60': optional: true - '@github/copilot-linux-x64@1.0.63': + '@github/copilot-linux-x64@1.0.60': optional: true - '@github/copilot-linuxmusl-arm64@1.0.63': + '@github/copilot-linuxmusl-arm64@1.0.60': optional: true - '@github/copilot-linuxmusl-x64@1.0.63': + '@github/copilot-linuxmusl-x64@1.0.60': optional: true - '@github/copilot-sdk@0.2.2(typescript@6.0.3)': + '@github/copilot-sdk@0.2.2': dependencies: - '@github/copilot': 1.0.63(typescript@6.0.3) + '@github/copilot': 1.0.60 vscode-jsonrpc: 8.2.1 zod: 4.4.3 - transitivePeerDependencies: - - typescript - '@github/copilot-win32-arm64@1.0.63': + '@github/copilot-win32-arm64@1.0.60': optional: true - '@github/copilot-win32-x64@1.0.63': + '@github/copilot-win32-x64@1.0.60': optional: true - '@github/copilot@1.0.63(typescript@6.0.3)': + '@github/copilot@1.0.60': dependencies: detect-libc: 2.1.2 - os-theme: 0.0.8(typescript@6.0.3) optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.63 - '@github/copilot-darwin-x64': 1.0.63 - '@github/copilot-linux-arm64': 1.0.63 - '@github/copilot-linux-x64': 1.0.63 - '@github/copilot-linuxmusl-arm64': 1.0.63 - '@github/copilot-linuxmusl-x64': 1.0.63 - '@github/copilot-win32-arm64': 1.0.63 - '@github/copilot-win32-x64': 1.0.63 - transitivePeerDependencies: - - typescript + '@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: @@ -13197,15 +13175,6 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@os-theme/darwin-arm64@0.0.8': - optional: true - - '@os-theme/linux-x64@0.0.8': - optional: true - - '@os-theme/win32-x64@0.0.8': - optional: true - '@oslojs/encoding@1.1.0': {} '@oxc-project/runtime@0.138.0': {} @@ -18630,14 +18599,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - os-theme@0.0.8(typescript@6.0.3): - dependencies: - typescript: 6.0.3 - optionalDependencies: - '@os-theme/darwin-arm64': 0.0.8 - '@os-theme/linux-x64': 0.0.8 - '@os-theme/win32-x64': 0.0.8 - outvariant@1.4.3: {} oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index c7e33f6fc6f..d10dea06560 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": "^0.2.2", + "@t3tools/contracts": "workspace:*", + }, + {}, + ), + { + "@github/copilot": "1.0.60", + "@github/copilot-sdk": "^0.2.2", + }, + ); + }); + it("carries only staged dependency patch metadata into staged desktop installs", () => { assert.deepStrictEqual( createStagePnpmConfig( From 2a0ccfd319a44b46823299aca2fbe4b2a69e25af Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 14:38:09 +0200 Subject: [PATCH 020/104] Update Copilot SDK runtime integration --- apps/server/package.json | 2 +- .../src/provider/Drivers/CopilotDriver.ts | 39 ++++-- .../provider/Layers/CopilotAdapter.test.ts | 85 ++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 120 ++++++++++++++++-- .../src/provider/Layers/CopilotProvider.ts | 2 + .../src/provider/copilotRuntime.test.ts | 79 +++++++++++- apps/server/src/provider/copilotRuntime.ts | 81 +++++++++++- .../CopilotTextGeneration.test.ts | 79 ++++++++---- .../textGeneration/CopilotTextGeneration.ts | 4 + pnpm-lock.yaml | 10 +- scripts/build-desktop-artifact.test.ts | 4 +- 11 files changed, 434 insertions(+), 71 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 8ac31679144..917c92bf7d5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,7 +29,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "^0.9.4", "@github/copilot": "1.0.60", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot-sdk": "1.0.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index 27c7bc4bf9b..d5c11e7698c 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -1,5 +1,6 @@ import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, Path, Schema, Stream } from "effect"; +import * as FileSystem from "effect/FileSystem"; import { makeCopilotTextGeneration } from "../../textGeneration/CopilotTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; @@ -11,11 +12,7 @@ import { } 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 ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; @@ -24,7 +21,11 @@ const DRIVER_KIND = ProviderDriverKind.make("copilot"); const SNAPSHOT_REFRESH_INTERVAL = Duration.hours(1); const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); -export type CopilotDriverEnv = Path.Path | ProviderEventLoggers | ServerConfig; +export type CopilotDriverEnv = + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig; const withInstanceIdentity = (input: { @@ -54,12 +55,15 @@ export const CopilotDriver: ProviderDriver = 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 continuationIdentity = defaultProviderContinuationIdentity({ + const baseDirectory = path.join(serverConfig.stateDir, "providers", "copilot", instanceId); + const continuationIdentity = { driverKind: DRIVER_KIND, - instanceId, - }); + continuationKey: `copilot:home:${baseDirectory}`, + }; const stampIdentity = withInstanceIdentity({ instanceId, displayName, @@ -70,13 +74,27 @@ export const CopilotDriver: ProviderDriver = 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); + const textGeneration = yield* makeCopilotTextGeneration(effectiveConfig, processEnv, { + baseDirectory, + }); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities, @@ -88,6 +106,7 @@ export const CopilotDriver: ProviderDriver = checkProvider: checkCopilotProviderStatus({ settings: effectiveConfig, cwd: serverConfig.cwd, + baseDirectory, environment: processEnv, }).pipe(Effect.map(stampIdentity)), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 9bb883a9813..c6f01820ef2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -13,7 +13,12 @@ import { it } from "@effect/vitest"; import { DateTime, Effect, Fiber, Layer, Stream } from "effect"; import { beforeEach, vi } from "vitest"; -import { type ProviderRuntimeEvent, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderRuntimeEvent, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -115,7 +120,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, }); - assert.deepStrictEqual(result, { kind: "denied-interactively-by-user" }); + assert.deepStrictEqual(result, { kind: "reject" }); return runtimeMock.state.lastSession as unknown as CopilotSession; }; @@ -143,7 +148,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, }); - assert.deepStrictEqual(result, { kind: "approved" }); + assert.deepStrictEqual(result, { kind: "approve-once" }); return runtimeMock.state.lastSession as unknown as CopilotSession; }; @@ -198,6 +203,80 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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 config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "shell", + toolCallId: "tool-shell-session-approval", + fullCommandText: "git status", + intention: "Check repository status", + commands: [{ identifier: "git", readOnly: true }], + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: true, + }; + const requestId = "permission-shell-session-approval"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-permission-session-approval", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: { + kind: "commands", + toolCallId: "tool-shell-session-approval", + fullCommandText: "git status", + intention: "Check repository status", + commandIdentifiers: ["git"], + canOfferSessionApproval: true, + }, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { + kind: "approve-for-session", + approval: { + kind: "commands", + commandIdentifiers: ["git"], + }, + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect( "renders Copilot Task_complete tool output as assistant text when no assistant message arrives", () => diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index c921eb2c6e1..5e779cf3377 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -55,10 +55,13 @@ type CopilotUserInputResponse = Awaited< type SessionPermissionRequestedEvent = Extract; type SessionUserInputRequestedEvent = Extract; type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; +type SessionApprovalDecision = Extract; +type SessionApproval = NonNullable; interface CopilotAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly baseDirectory?: string; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -84,6 +87,8 @@ interface PendingPermissionBinding { | "command_execution_approval" | "file_read_approval" | "file_change_approval"; + readonly permissionRequest: SessionPermissionRequest; + readonly promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined; readonly deferred: Deferred.Deferred; } @@ -151,9 +156,9 @@ interface CopilotSessionContext { stopped: boolean; } -const APPROVED_PERMISSION_RESULT = { kind: "approved" } satisfies PermissionRequestResult; +const APPROVED_PERMISSION_RESULT = { kind: "approve-once" } satisfies PermissionRequestResult; const DENIED_PERMISSION_RESULT = { - kind: "denied-interactively-by-user", + kind: "reject", } satisfies PermissionRequestResult; const EMPTY_USER_INPUT_RESPONSE = { answer: "", @@ -374,16 +379,80 @@ function permissionDetail(request: SessionPermissionRequest): string | undefined 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 permissionSignature(request: { - readonly kind: string; - readonly toolCallId?: string; - readonly [key: string]: unknown; -}): string { +function sessionApprovalDecisionFromPermissionRequest( + request: SessionPermissionRequest, + promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined, +): SessionApprovalDecision | undefined { + const approve = (approval: SessionApproval): SessionApprovalDecision => ({ + kind: "approve-for-session", + approval, + }); + + switch (request.kind) { + case "shell": { + if (!request.canOfferSessionApproval) { + return undefined; + } + const commandIdentifiers = + promptRequest?.kind === "commands" + ? promptRequest.commandIdentifiers + : request.commands.map((command) => command.identifier); + const identifiers = commandIdentifiers.map((identifier) => identifier.trim()).filter(Boolean); + return identifiers.length > 0 + ? approve({ kind: "commands", commandIdentifiers: identifiers }) + : undefined; + } + case "write": + return request.canOfferSessionApproval ? approve({ kind: "write" }) : undefined; + case "read": + return approve({ kind: "read" }); + case "mcp": + return approve({ kind: "mcp", serverName: request.serverName, toolName: request.toolName }); + case "url": { + try { + const domain = new URL(request.url).hostname.trim(); + return domain ? { kind: "approve-for-session", domain } : undefined; + } catch { + return undefined; + } + } + case "memory": + return approve({ kind: "memory" }); + case "custom-tool": + return approve({ kind: "custom-tool", toolName: request.toolName }); + case "extension-management": { + const operation = trimOrUndefined(request.operation); + return approve({ + kind: "extension-management", + ...(operation ? { operation } : {}), + }); + } + case "extension-permission-access": + return approve({ + kind: "extension-permission-access", + extensionName: request.extensionName, + }); + case "hook": + return undefined; + } +} + +function permissionSignature(request: SessionPermissionRequest): string { switch (request.kind) { case "shell": return JSON.stringify([ @@ -433,6 +502,20 @@ function permissionSignature(request: { 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); } @@ -1112,6 +1195,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.pendingPermissionBindings.set(requestId, { requestId, requestType: mapPermissionRequestType(eventData.permissionRequest), + permissionRequest: eventData.permissionRequest, + promptRequest: eventData.promptRequest, deferred: handler.deferred, }); if ( @@ -1187,7 +1272,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return DENIED_PERMISSION_RESULT; } - const signature = permissionSignature(request as PermissionRequest & { kind: string }); + const signature = permissionSignature(request); const deferred = yield* Deferred.make(); const queue = context.pendingPermissionHandlersBySignature.get(signature) ?? []; queue.push({ @@ -1912,9 +1997,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (event.data.resolvedByHook === true) { return; } - const signature = permissionSignature( - event.data.permissionRequest as SessionPermissionRequest & { kind: string }, - ); + const signature = permissionSignature(event.data.permissionRequest); const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; queue.push(event.data); context.pendingPermissionEventsBySignature.set(signature, queue); @@ -2051,6 +2134,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( createCopilotClient({ settings, cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), ...(options?.environment ? { env: options.environment } : {}), logLevel: "error", }), @@ -2343,10 +2427,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); } + const approvalDecision = sessionApprovalDecisionFromPermissionRequest( + binding.permissionRequest, + binding.promptRequest, + ); const result: PermissionRequestResult = - decision === "accept" || decision === "acceptForSession" - ? { kind: "approved" } - : { kind: "denied-interactively-by-user" }; + decision === "accept" + ? APPROVED_PERMISSION_RESULT + : decision === "acceptForSession" && approvalDecision + ? approvalDecision + : decision === "acceptForSession" + ? APPROVED_PERMISSION_RESULT + : DENIED_PERMISSION_RESULT; yield* Deferred.succeed(binding.deferred, result); }, ); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index d0d4f35398e..90987b86ffa 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -60,6 +60,7 @@ export function makePendingCopilotProvider(settings: CopilotSettings): ServerPro 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) { @@ -97,6 +98,7 @@ export function checkCopilotProviderStatus(input: { createCopilotClient({ settings: input.settings, cwd: input.cwd, + ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), ...(input.environment ? { env: input.environment } : {}), logLevel: "error", }), diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index da180421a5a..30192e94bef 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -5,10 +5,77 @@ import { describe, it } from "vitest"; import { authSnapshotFromCopilotSdk, buildCopilotClientOptions, + normalizeCopilotRuntimeEnvironment, resolveBundledCopilotCliPath, } from "./copilotRuntime.ts"; +function assertStdioConnection( + connection: ReturnType["connection"], +) { + assert.equal(connection?.kind, "stdio"); + return connection; +} + +const POSIX_SHELL_FALLBACKS = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; + describe("buildCopilotClientOptions", () => { + it("leaves POSIX PATH hydration to the shared server environment setup", () => { + const env = normalizeCopilotRuntimeEnvironment({ PATH: "/custom/bin:/bin" }, "darwin"); + + assert.equal(env.PATH, "/custom/bin:/bin"); + }); + + it("hydrates a missing POSIX SHELL for Copilot shell spawning", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + assert.ok(POSIX_SHELL_FALLBACKS.some((shell) => shell === env.SHELL)); + }); + + it("replaces POSIX SHELL values that the Copilot CLI rejects", () => { + const fallbackShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; + const relativeShellEnv = normalizeCopilotRuntimeEnvironment({ SHELL: "bash" }, "darwin"); + const shellWithWhitespaceEnv = normalizeCopilotRuntimeEnvironment( + { SHELL: "/bin/bash --noprofile" }, + "darwin", + ); + + assert.equal(relativeShellEnv.SHELL, fallbackShell); + assert.equal(shellWithWhitespaceEnv.SHELL, fallbackShell); + }); + + it("preserves valid POSIX SHELL paths", () => { + const validShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; + assert.ok(validShell); + + const env = normalizeCopilotRuntimeEnvironment({ SHELL: validShell }, "darwin"); + + assert.equal(env.SHELL, validShell); + }); + + it("forces the Copilot POSIX shell spawn backend to avoid node-pty failures", () => { + const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); + + assert.equal(env.COPILOT_FEATURE_FLAGS, "SHELL_SPAWN_BACKEND"); + assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, "true"); + }); + + it("preserves existing Copilot feature flags while enabling the shell spawn backend", () => { + const env = normalizeCopilotRuntimeEnvironment( + { COPILOT_FEATURE_FLAGS: "FOCUSED_TOOLS, SHELL_SPAWN_BACKEND, MCP_APPS" }, + "darwin", + ); + + assert.equal(env.COPILOT_FEATURE_FLAGS, "FOCUSED_TOOLS,SHELL_SPAWN_BACKEND,MCP_APPS"); + }); + + it("does not apply POSIX shell normalization on Windows", () => { + const env = normalizeCopilotRuntimeEnvironment({ SHELL: "bash" }, "win32"); + + assert.equal(env.SHELL, "bash"); + assert.equal(env.COPILOT_FEATURE_FLAGS, undefined); + assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, undefined); + }); + it("strips inherited COPILOT_CLI_PATH and uses the local Copilot CLI shim by default", () => { const options = buildCopilotClientOptions({ settings: { @@ -18,6 +85,7 @@ describe("buildCopilotClientOptions", () => { customModels: [], }, cwd: "/tmp/project", + baseDirectory: "/tmp/t3-copilot-home", env: { PATH: "/usr/bin", COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", @@ -26,11 +94,15 @@ describe("buildCopilotClientOptions", () => { logLevel: "error", }); - assert.ok(options.cliPath?.includes("node_modules/.bin/copilot")); - assert.equal(options.cwd, "/tmp/project"); + const connection = assertStdioConnection(options.connection); + assert.ok(connection.path?.includes("node_modules/.bin/copilot")); + assert.equal(options.workingDirectory, "/tmp/project"); + assert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); assert.equal(options.logLevel, "error"); + assert.equal(options.mode, "copilot-cli"); assert.equal(options.env?.COPILOT_CLI_PATH, undefined); assert.equal(options.env?.GITHUB_TOKEN, "github-token"); + assert.equal(options.env?.PATH, "/usr/bin"); }); it("resolves the bundled Copilot CLI shim without relying on PATH", () => { @@ -57,7 +129,8 @@ describe("buildCopilotClientOptions", () => { }, }); - assert.equal(options.cliPath, configuredBinaryPath); + const connection = assertStdioConnection(options.connection); + assert.equal(connection.path, configuredBinaryPath); assert.equal(options.env?.COPILOT_CLI_PATH, undefined); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 52954a31266..e5f950a9a00 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -1,12 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off import { CopilotClient, + RuntimeConnection, type CopilotClientOptions, type GetAuthStatusResponse, type GetStatusResponse, type ModelInfo, } from "@github/copilot-sdk"; -import { dirname, join } from "node:path"; +import { accessSync, constants as fsConstants, statSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { CopilotSettings, @@ -40,6 +42,10 @@ const GENERIC_EFFECT_TRY_PROMISE_MESSAGES = new Set([ ]); const COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH"; const COPILOT_CLI_COMMAND = "copilot"; +const COPILOT_FEATURE_FLAGS_ENV = "COPILOT_FEATURE_FLAGS"; +const COPILOT_SHELL_SPAWN_BACKEND_FLAG = "SHELL_SPAWN_BACKEND"; +const COPILOT_SHELL_SPAWN_BACKEND_EXP_ENV = "COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND"; +const COPILOT_POSIX_SHELL_CANDIDATES = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; export class CopilotProbePromiseError extends Error { override readonly cause: unknown; @@ -107,6 +113,7 @@ function normalizeAuthLabelPart(value: string | null | undefined): string | unde export function createCopilotClient(input: { readonly settings: CopilotSettings; readonly cwd?: string; + readonly baseDirectory?: string; readonly env?: Record; readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; @@ -114,6 +121,63 @@ export function createCopilotClient(input: { return new CopilotClient(buildCopilotClientOptions(input)); } +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + + accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function isValidPosixShellPath(path: string | undefined): path is string { + return !!path && !/\s/.test(path) && isAbsolute(path) && isExecutableFile(path); +} + +function resolvePosixShellPath(currentShell: string | undefined): string | undefined { + if (isValidPosixShellPath(currentShell)) { + return currentShell; + } + + return COPILOT_POSIX_SHELL_CANDIDATES.find(isExecutableFile); +} + +function appendCommaSeparatedValue(value: string | undefined, entry: string): string { + const entries = + value + ?.split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) ?? []; + + return entries.includes(entry) ? entries.join(",") : [...entries, entry].join(","); +} + +export function normalizeCopilotRuntimeEnvironment( + input: Record, + platform: NodeJS.Platform = process.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; +} + function validateConfiguredCopilotCliPath(input: { readonly settings: CopilotSettings; readonly env?: Record; @@ -186,18 +250,20 @@ export function resolveBundledCopilotCliPath(input: { export function buildCopilotClientOptions(input: { readonly settings: CopilotSettings; readonly cwd?: string; + readonly baseDirectory?: string; readonly env?: Record; readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; }): CopilotClientOptions { const cliUrl = trimOrUndefined(input.settings.serverUrl); - const env = { ...process.env }; + let env: Record = { ...process.env }; if (input.env) { Object.assign(env, input.env); } delete env[COPILOT_CLI_PATH_ENV]; + env = normalizeCopilotRuntimeEnvironment(env); const configuredCliPath = validateConfiguredCopilotCliPath({ settings: input.settings, @@ -210,9 +276,14 @@ export function buildCopilotClientOptions(input: { const cliPath = configuredCliPath ?? bundledCliPath; return { - ...(cliUrl ? { cliUrl } : {}), - ...(!cliUrl && cliPath ? { cliPath } : {}), - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cliUrl + ? { connection: RuntimeConnection.forUri(cliUrl) } + : cliPath + ? { connection: RuntimeConnection.forStdio({ path: cliPath }) } + : {}), + mode: "copilot-cli", + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), env, ...(input.logLevel ? { logLevel: input.logLevel } : {}), ...(input.onListModels ? { onListModels: input.onListModels } : {}), diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index f57d8ebe491..a5f7ecddb27 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -11,7 +11,7 @@ import { makeCopilotTextGeneration } from "./CopilotTextGeneration.ts"; const runtimeMock = vi.hoisted(() => { const state = { createdClients: [] as Array<{ - readonly input: { readonly cwd?: string }; + readonly input: { readonly cwd?: string; readonly baseDirectory?: string }; readonly client: { readonly start: ReturnType; readonly stop: ReturnType; @@ -40,34 +40,36 @@ vi.mock("../provider/copilotRuntime.ts", async () => { return { ...actual, - createCopilotClient: vi.fn((input: { readonly cwd?: string }) => { - const start = vi.fn(async () => undefined); - const stop = vi.fn(async () => undefined); - const createSession = vi.fn(async () => { - const sendAndWait = vi.fn(async () => ({ - data: { - content: JSON.stringify({ - subject: "Add change", - body: "", - }), - }, - })); - const disconnect = vi.fn(async () => undefined); - runtimeMock.state.sessions.push({ disconnect, sendAndWait }); - return { - sendAndWait, - disconnect, + createCopilotClient: vi.fn( + (input: { readonly cwd?: string; readonly baseDirectory?: string }) => { + const start = vi.fn(async () => undefined); + const stop = vi.fn(async () => undefined); + const createSession = vi.fn(async () => { + const sendAndWait = vi.fn(async () => ({ + data: { + content: JSON.stringify({ + subject: "Add change", + body: "", + }), + }, + })); + const disconnect = vi.fn(async () => undefined); + runtimeMock.state.sessions.push({ disconnect, sendAndWait }); + return { + sendAndWait, + disconnect, + }; + }); + + const client = { + start, + stop, + createSession, }; - }); - - const client = { - start, - stop, - createSession, - }; - runtimeMock.state.createdClients.push({ input, client }); - return client; - }), + runtimeMock.state.createdClients.push({ input, client }); + return client; + }, + ), }; }); @@ -112,6 +114,7 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { 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; @@ -125,4 +128,24 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { expect(runtimeMock.state.sessions[1]?.disconnect).toHaveBeenCalledTimes(1); }), ); + + it.effect("passes the configured Copilot base directory to shared clients", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings, process.env, { + baseDirectory: "/tmp/t3-copilot-home", + }); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-home", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.createdClients[0]?.input.baseDirectory).toBe("/tmp/t3-copilot-home"); + }), + ); }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 9f00d772641..4ed96c0b80e 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -99,6 +99,9 @@ function copilotTextClientKey(input: { 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) => @@ -182,6 +185,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( createCopilotClient({ settings: input.settings, cwd: input.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), env: environment, logLevel: "error", }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39d8c04c7b1..fd7aaf7e144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,8 +466,8 @@ importers: specifier: 1.0.60 version: 1.0.60 '@github/copilot-sdk': - specifier: ^0.2.2 - version: 0.2.2 + specifier: 1.0.0 + version: 1.0.0 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2734,8 +2734,8 @@ packages: os: [linux] hasBin: true - '@github/copilot-sdk@0.2.2': - resolution: {integrity: sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==} + '@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': @@ -12564,7 +12564,7 @@ snapshots: '@github/copilot-linuxmusl-x64@1.0.60': optional: true - '@github/copilot-sdk@0.2.2': + '@github/copilot-sdk@1.0.0': dependencies: '@github/copilot': 1.0.60 vscode-jsonrpc: 8.2.1 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d10dea06560..fbb73d04989 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -171,14 +171,14 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { resolveDesktopRuntimeDependencies( { "@github/copilot": "1.0.60", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot-sdk": "1.0.0", "@t3tools/contracts": "workspace:*", }, {}, ), { "@github/copilot": "1.0.60", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot-sdk": "1.0.0", }, ); }); From c63d8aafc1806c0e176c029a7240003a37d7b242 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:14:15 +0200 Subject: [PATCH 021/104] Add Copilot context window selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 64 +++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 22 +++- .../src/provider/copilotRuntime.test.ts | 60 ++++++++++ apps/server/src/provider/copilotRuntime.ts | 108 +++++++++++++++--- apps/web/src/components/chat/TraitsPicker.tsx | 4 +- .../chat/composerProviderState.test.tsx | 21 ++++ .../components/chat/composerProviderState.tsx | 4 +- 7 files changed, 263 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index c6f01820ef2..c6c8d62898e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -17,6 +17,7 @@ import { ApprovalRequestId, type ProviderRuntimeEvent, ProviderDriverKind, + ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -27,6 +28,7 @@ import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const COPILOT_DRIVER = ProviderDriverKind.make("copilot"); +const COPILOT_INSTANCE_ID = ProviderInstanceId.make("copilot"); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const waitForSdkEventQueue = () => Effect.promise(() => sleep(10).then(() => undefined)); @@ -203,6 +205,68 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("passes selected Copilot context tier when creating a session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-start-session-context-tier"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "contextTier", value: "long_context" }, + ], + }, + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.equal(config?.model, "claude-sonnet-4.6"); + assert.equal(config?.reasoningEffort, "high"); + assert.equal(config?.contextTier, "long_context"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("passes selected Copilot context tier when changing models for a turn", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-turn-context-tier"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.setModel.mockClear(); + yield* adapter.sendTurn({ + threadId, + input: "Use the long context tier", + attachments: [], + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [{ id: "contextTier", value: "long_context" }], + }, + }); + + assert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls.at(-1), [ + "claude-sonnet-4.6", + { contextTier: "long_context" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("returns a session-scoped SDK approval for acceptForSession", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 5e779cf3377..d6a838f017f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import type { CopilotClient, CopilotSession, + ContextTier, MessageOptions, PermissionRequest, PermissionRequestResult, @@ -48,6 +49,7 @@ const COPILOT_RESUME_SCHEMA_VERSION = 1 as const; type CopilotMode = "interactive" | "plan" | "autopilot"; type CopilotReasoningEffort = NonNullable; +type CopilotContextTier = ContextTier; type CopilotUserInputRequest = Parameters>[0]; type CopilotUserInputResponse = Awaited< ReturnType> @@ -871,9 +873,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context: CopilotSessionContext, model: string, reasoningEffort?: CopilotReasoningEffort | undefined, + contextTier?: CopilotContextTier | undefined, ): Effect.Effect => Effect.tryPromise({ - try: () => context.sdkSession.setModel(model, reasoningEffort ? { reasoningEffort } : {}), + try: () => + context.sdkSession.setModel(model, { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), + }), catch: (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, @@ -2101,6 +2108,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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) => { @@ -2150,6 +2160,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( clientName: "t3-code", ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), workingDirectory: cwd, streaming: true, enableConfigDiscovery: true, @@ -2159,6 +2170,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( | "clientName" | "model" | "reasoningEffort" + | "contextTier" | "workingDirectory" | "streaming" | "enableConfigDiscovery" @@ -2303,11 +2315,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") as | CopilotReasoningEffort | undefined; + const contextTier = getModelSelectionStringOptionValue(modelSelection, "contextTier") as + | CopilotContextTier + | undefined; if (modelSelection?.model) { - yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort); + yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort, contextTier); updateProviderSession(context, { model: modelSelection.model, - ...(reasoningEffort ? { status: "ready" } : {}), + ...(reasoningEffort || contextTier ? { status: "ready" } : {}), }); } @@ -2335,6 +2350,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( payload: { model: modelSelection?.model ?? context.session.model, ...(reasoningEffort ? { effort: reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), }, }); diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 30192e94bef..e806d825000 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -5,6 +5,7 @@ import { describe, it } from "vitest"; import { authSnapshotFromCopilotSdk, buildCopilotClientOptions, + capabilitiesFromCopilotModel, normalizeCopilotRuntimeEnvironment, resolveBundledCopilotCliPath, } from "./copilotRuntime.ts"; @@ -25,6 +26,65 @@ describe("buildCopilotClientOptions", () => { assert.equal(env.PATH, "/custom/bin:/bin"); }); + describe("capabilitiesFromCopilotModel", () => { + it("adds a context tier selector for long-context Copilot models", () => { + const capabilities = capabilitiesFromCopilotModel({ + capabilities: { + supports: { vision: false, reasoningEffort: true }, + limits: { max_prompt_tokens: 922_000, max_context_window_tokens: 1_050_000 }, + }, + billing: { + tokenPrices: { + contextMax: 272_000, + longContext: { contextMax: 922_000 }, + }, + }, + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "contextTier", + label: "Context Window", + type: "select", + options: [ + { id: "default", label: "Default (272K tokens)" }, + { id: "long_context", label: "Long Context (1.05M tokens)" }, + ], + currentValue: "default", + }, + ]); + }); + + it("omits the context tier selector for regular-context Copilot models", () => { + const capabilities = capabilitiesFromCopilotModel({ + capabilities: { + supports: { vision: false, reasoningEffort: false }, + limits: { max_prompt_tokens: 272_000, max_context_window_tokens: 400_000 }, + }, + billing: { + tokenPrices: { + contextMax: 272_000, + }, + }, + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, []); + }); + }); + it("hydrates a missing POSIX SHELL for Copilot shell spawning", () => { const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index e5f950a9a00..ee909b64433 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import type { CopilotSettings, ModelCapabilities, + ProviderOptionDescriptor, ServerProviderAuth, ServerProviderModel, ServerProviderState, @@ -294,8 +295,61 @@ export function versionFromCopilotStatus(status: GetStatusResponse): string | nu 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< + ModelInfo, + "capabilities" | "supportedReasoningEfforts" | "defaultReasoningEffort" +> & { + 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: Pick, + model: CopilotModelInfoForCapabilities, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts?.map((effort) => ({ @@ -304,21 +358,45 @@ export function capabilitiesFromCopilotModel( ...(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 } : {}), + }); + } - return createModelCapabilities({ - optionDescriptors: - reasoningOptions.length > 0 - ? [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select" as const, - 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: { 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..73bbba188ba 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -165,6 +165,27 @@ describe("getComposerProviderState", () => { ); }); + it("does not treat context tier as promptEffort when no reasoning descriptor exists", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("contextTier", [ + { id: "default", label: "Default", isDefault: true }, + { id: "long_context", label: "Long Context" }, + ]), + ]), + prompt: "", + modelOptions: selections(["contextTier", "long_context"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["contextTier", "long_context"]), + }); + }); + it("returns undefined dispatch options when the model declares no descriptors", () => { const state = getComposerProviderState({ provider: PROVIDER, diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1349e2509b7..c1b0e71f836 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -17,6 +17,8 @@ import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +const PROMPT_EFFORT_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning", "variant"]); + export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; @@ -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; From 596e2c8d22275eb3fd5e3aaaea0a7114e1afcfcd Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:20:12 +0200 Subject: [PATCH 022/104] Label Copilot none reasoning effort Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/provider/copilotRuntime.test.ts | 3 ++- apps/server/src/provider/copilotRuntime.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index e806d825000..db125de7410 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -39,7 +39,7 @@ describe("buildCopilotClientOptions", () => { longContext: { contextMax: 922_000 }, }, }, - supportedReasoningEfforts: ["low", "medium", "high"], + supportedReasoningEfforts: ["none", "low", "medium", "high"], defaultReasoningEffort: "medium", }); @@ -49,6 +49,7 @@ describe("buildCopilotClientOptions", () => { label: "Reasoning", type: "select", options: [ + { id: "none", label: "None" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium", isDefault: true }, { id: "high", label: "High" }, diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index ee909b64433..a7a9f89332f 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -30,7 +30,8 @@ export const EMPTY_COPILOT_MODEL_CAPABILITIES: ModelCapabilities = createModelCa optionDescriptors: [], }); -const COPILOT_REASONING_LABELS = { +const COPILOT_REASONING_LABELS: Readonly> = { + none: "None", low: "Low", medium: "Medium", high: "High", @@ -311,10 +312,9 @@ function formatContextTierLabel(label: string, tokens: number | undefined): stri : label; } -type CopilotModelInfoForCapabilities = Pick< - ModelInfo, - "capabilities" | "supportedReasoningEfforts" | "defaultReasoningEffort" -> & { +type CopilotModelInfoForCapabilities = Pick & { + readonly supportedReasoningEfforts?: ReadonlyArray; + readonly defaultReasoningEffort?: string; readonly billing?: unknown; }; @@ -354,7 +354,7 @@ export function capabilitiesFromCopilotModel( const reasoningOptions = model.supportedReasoningEfforts?.map((effort) => ({ id: effort, - label: COPILOT_REASONING_LABELS[effort as keyof typeof COPILOT_REASONING_LABELS] ?? effort, + label: COPILOT_REASONING_LABELS[effort] ?? effort, ...(model.defaultReasoningEffort === effort ? { isDefault: true } : {}), })) ?? []; const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; From 05875ade839cc2387c1a1d0757245410db49ecb5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:57:10 +0200 Subject: [PATCH 023/104] Refresh Copilot provider timestamps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotProvider.test.ts | 21 +++++++++++++++++++ .../src/provider/Layers/CopilotProvider.ts | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 2532a8f8df4..2145df271f5 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -58,6 +58,7 @@ vi.mock("../copilotRuntime.ts", async () => { }); beforeEach(() => { + vi.useRealTimers(); runtimeMock.reset(); }); @@ -101,4 +102,24 @@ describe("CopilotProvider status", () => { ); }), ); + + 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(new Date("2026-06-08T12:00:00.000Z")); + const firstSnapshot = yield* statusCheck; + + vi.setSystemTime(new Date("2026-06-08T12:01:00.000Z")); + const secondSnapshot = yield* statusCheck; + + assert.equal(firstSnapshot.checkedAt, "2026-06-08T12:00:00.000Z"); + assert.equal(secondSnapshot.checkedAt, "2026-06-08T12:01:00.000Z"); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 90987b86ffa..2b046bb92ea 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -67,8 +67,8 @@ export function checkCopilotProviderStatus(input: { return Effect.succeed(makePendingCopilotProvider(input.settings)); } - const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); const fallback = (cause: unknown, version: string | null = null) => { + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); const failure = formatCopilotProbeError({ cause, settings: input.settings, @@ -107,6 +107,7 @@ export function checkCopilotProviderStatus(input: { (client) => Effect.tryPromise({ try: async () => { + const checkedAt = DateTime.formatIso(DateTime.nowUnsafe()); await client.start(); const [status, authStatus, models] = await Promise.all([ client.getStatus(), From c69a6709e970aa1d23050d59a6ed714a018852ad Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:57:59 +0200 Subject: [PATCH 024/104] Clear Copilot permission bindings on reply Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 9 +++++++++ apps/server/src/provider/Layers/CopilotAdapter.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index c6c8d62898e..58b3d0bf94b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -337,6 +337,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }); + const duplicateReply = yield* Effect.flip( + adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ), + ); + assert.match(duplicateReply.message, /Unknown pending permission request/); + yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index d6a838f017f..46328221967 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2456,6 +2456,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ? APPROVED_PERMISSION_RESULT : DENIED_PERMISSION_RESULT; yield* Deferred.succeed(binding.deferred, result); + context.pendingPermissionBindings.delete(requestId); }, ); From 0c9452a7d42fbb3e4905c24ef90e8bea577ae9b7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:01:40 +0200 Subject: [PATCH 025/104] Drain Copilot events before teardown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 108 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 9 ++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 58b3d0bf94b..38b5d29785f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -24,6 +24,7 @@ import { import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); @@ -52,6 +53,8 @@ const runtimeMock = vi.hoisted(() => { const state = { startCalls: 0, stopCalls: 0, + nativeWriteCalls: 0, + nativeWriteGate: null as Promise | null, createSessionConfigs: [] as SessionConfig[], createSessionImpl: null as ((config: SessionConfig) => Promise) | null, lastSession: makeSession(), @@ -62,6 +65,8 @@ const runtimeMock = vi.hoisted(() => { reset() { state.startCalls = 0; state.stopCalls = 0; + state.nativeWriteCalls = 0; + state.nativeWriteGate = null; state.createSessionConfigs.length = 0; state.lastSession = makeSession(); state.createSessionImpl = async () => state.lastSession as unknown as CopilotSession; @@ -102,7 +107,21 @@ beforeEach(() => { runtimeMock.reset(); }); -const CopilotAdapterTestLayer = makeCopilotAdapterLive().pipe( +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.succeed(undefined)), +} satisfies EventNdjsonLogger; + +const CopilotAdapterTestLayer = makeCopilotAdapterLive({ nativeEventLogger }).pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-copilot-adapter-test-", @@ -641,6 +660,93 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("drains queued SDK events before disconnecting on stop", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-drains-event-chain"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "finish while stop is requested", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + let releaseNativeWrite: () => void = () => undefined; + runtimeMock.state.nativeWriteGate = new Promise((resolve) => { + releaseNativeWrite = resolve; + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-stop-drain-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-stop-drain", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-stop-drain-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + for (let attempt = 0; attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; attempt += 1) { + yield* waitForSdkEventQueue(); + } + + const disconnectsBeforeDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; + releaseNativeWrite(); + yield* Fiber.join(stopFiber); + + for ( + let attempt = 0; + attempt < 20 && !runtimeEvents.some((event) => event.type === "turn.completed"); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(runtimeMock.state.nativeWriteCalls > 0, true); + assert.equal(disconnectsBeforeDrain, 0); + assert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); + assert.equal(runtimeMock.state.stopCalls, 1); + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }), + ); + it.effect("completes the turn as failed when Copilot send rejects", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 46328221967..b1b3ed83868 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2490,6 +2490,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } context.stopped = true; + yield* Effect.promise(() => context.eventChain).pipe( + Effect.mapError((cause) => + processError( + threadId, + detailFromCause(cause, "Failed to process Copilot session events."), + cause, + ), + ), + ); yield* settlePendingPermissionHandlers(context); yield* settlePendingUserInputs(context); yield* copilotSdk.disconnect(context).pipe(Effect.ignore); From 617b1fc6a12cd3e7456c84e3a5ff4833a527f937 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:04:01 +0200 Subject: [PATCH 026/104] Clean up Copilot regression tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/provider/Layers/CopilotAdapter.test.ts | 14 +++++++------- apps/server/src/provider/Layers/CopilotAdapter.ts | 10 +--------- .../src/provider/Layers/CopilotProvider.test.ts | 6 +++--- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 38b5d29785f..6c348cdf0b2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -118,7 +118,7 @@ const nativeEventLogger = { } }), ), - close: vi.fn(() => Effect.succeed(undefined)), + close: vi.fn(() => Effect.void), } satisfies EventNdjsonLogger; const CopilotAdapterTestLayer = makeCopilotAdapterLive({ nativeEventLogger }).pipe( @@ -357,11 +357,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const duplicateReply = yield* Effect.flip( - adapter.respondToRequest( - threadId, - ApprovalRequestId.make(requestId), - "acceptForSession", - ), + adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "acceptForSession"), ); assert.match(duplicateReply.message, /Unknown pending permission request/); @@ -716,7 +712,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } as SessionEvent); const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); - for (let attempt = 0; attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; attempt += 1) { + for ( + let attempt = 0; + attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; + attempt += 1 + ) { yield* waitForSdkEventQueue(); } diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b1b3ed83868..031fe5c832a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2490,15 +2490,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } context.stopped = true; - yield* Effect.promise(() => context.eventChain).pipe( - Effect.mapError((cause) => - processError( - threadId, - detailFromCause(cause, "Failed to process Copilot session events."), - cause, - ), - ), - ); + yield* Effect.promise(() => context.eventChain); yield* settlePendingPermissionHandlers(context); yield* settlePendingUserInputs(context); yield* copilotSdk.disconnect(context).pipe(Effect.ignore); diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 2145df271f5..e75cf76769d 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { it } from "@effect/vitest"; import { CopilotSettings } from "@t3tools/contracts"; -import { Effect, Schema } from "effect"; +import { DateTime, Effect, Schema } from "effect"; import { beforeEach, describe, vi } from "vitest"; import { checkCopilotProviderStatus } from "./CopilotProvider.ts"; @@ -112,10 +112,10 @@ describe("CopilotProvider status", () => { cwd: process.cwd(), }); - vi.setSystemTime(new Date("2026-06-08T12:00:00.000Z")); + vi.setSystemTime(DateTime.makeUnsafe("2026-06-08T12:00:00.000Z").epochMilliseconds); const firstSnapshot = yield* statusCheck; - vi.setSystemTime(new Date("2026-06-08T12:01:00.000Z")); + vi.setSystemTime(DateTime.makeUnsafe("2026-06-08T12:01:00.000Z").epochMilliseconds); const secondSnapshot = yield* statusCheck; assert.equal(firstSnapshot.checkedAt, "2026-06-08T12:00:00.000Z"); From b43f607658778aa749819af3581cacc1ece768ae Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:52:17 +0200 Subject: [PATCH 027/104] Add Copilot max reasoning effort Expose the new Copilot CLI max reasoning effort option in model capabilities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/provider/copilotRuntime.test.ts | 3 ++- apps/server/src/provider/copilotRuntime.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index db125de7410..61d764f814d 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -39,7 +39,7 @@ describe("buildCopilotClientOptions", () => { longContext: { contextMax: 922_000 }, }, }, - supportedReasoningEfforts: ["none", "low", "medium", "high"], + supportedReasoningEfforts: ["none", "low", "medium", "high", "max"], defaultReasoningEffort: "medium", }); @@ -53,6 +53,7 @@ describe("buildCopilotClientOptions", () => { { id: "low", label: "Low" }, { id: "medium", label: "Medium", isDefault: true }, { id: "high", label: "High" }, + { id: "max", label: "Max" }, ], currentValue: "medium", }, diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index a7a9f89332f..34fdf2caf67 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -36,6 +36,7 @@ const COPILOT_REASONING_LABELS: Readonly> = { medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", } as const; const GENERIC_EFFECT_TRY_PROMISE_MESSAGES = new Set([ From cc82771cb30a716710226c89b0a3d4375ab96e9b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:52:17 +0200 Subject: [PATCH 028/104] Align Copilot command tool rendering Emit command metadata separately from command output so Copilot command rows render like Codex. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 109 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 94 ++++++++++++--- apps/web/src/session-logic.test.ts | 32 +++++ 3 files changed, 220 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 6c348cdf0b2..db901cf5f07 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -577,6 +577,115 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits command metadata separately from command output", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-metadata"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "check git status", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command", + toolName: "bash", + arguments: { + command: "git status --short", + }, + }, + } as SessionEvent); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command", + success: true, + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find( + (event) => + event.type === "item.completed" && String(event.itemId) === "copilot-tool-tool-command", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.itemType, "command_execution"); + assert.equal(completed.payload.title, "Ran command"); + assert.equal( + completed.payload.detail, + "M apps/server/src/provider/Layers/CopilotAdapter.ts", + ); + assert.deepStrictEqual(completed.payload.data, { + toolCallId: "tool-command", + toolName: "bash", + command: "git status --short", + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores empty SDK tool progress messages without failing the session", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 031fe5c832a..d4c09a7ee94 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -112,6 +112,7 @@ interface ToolMeta { | "collab_agent_tool_call" | "web_search" | "image_view"; + readonly command?: string; } interface CopilotToolExecutionItem { @@ -608,6 +609,61 @@ function toolStreamKind( return "unknown"; } +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringRecord(value: unknown): Record | undefined { + return isStringRecord(value) ? value : undefined; +} + +function commandFromToolArguments(arguments_: unknown): string | undefined { + const args = stringRecord(arguments_); + if (!args) { + return undefined; + } + + const candidates = [ + args.command, + args.cmd, + args.fullCommandText, + args.commandText, + stringRecord(args.input)?.command, + ]; + for (const candidate of candidates) { + const command = trimOrUndefined(typeof candidate === "string" ? candidate : undefined); + if (command) { + return command; + } + } + return undefined; +} + +function toolLifecycleTitle(toolMeta: ToolMeta | undefined): string { + return toolMeta?.itemType === "command_execution" + ? "Ran command" + : (toolMeta?.toolName ?? "tool"); +} + +function toolLifecycleData(input: { + readonly toolCallId: string; + readonly toolMeta: ToolMeta | undefined; + readonly arguments?: Record | undefined; + readonly result?: unknown; + readonly error?: unknown; + readonly toolTelemetry?: unknown; +}): Record { + return { + ...input.arguments, + toolCallId: input.toolCallId, + ...(input.toolMeta?.toolName ? { toolName: input.toolMeta.toolName } : {}), + ...(input.toolMeta?.command ? { command: input.toolMeta.command } : {}), + ...(input.result ? { result: input.result } : {}), + ...(input.error ? { error: input.error } : {}), + ...(input.toolTelemetry ? { toolTelemetry: input.toolTelemetry } : {}), + }; +} + function usageSnapshotFromAssistantUsage( event: Extract, ): ThreadTokenUsageSnapshot { @@ -1871,9 +1927,17 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-tool-${event.data.toolCallId}`; const itemType = toolItemType(event.data.toolName, event.data.mcpServerName); - context.toolMetaById.set(event.data.toolCallId, { + const command = + itemType === "command_execution" + ? commandFromToolArguments(event.data.arguments) + : undefined; + const toolMeta: ToolMeta = { toolName: event.data.toolName, itemType, + ...(command ? { command } : {}), + }; + context.toolMetaById.set(event.data.toolCallId, { + ...toolMeta, }); context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); context.startedItemIds.add(itemId); @@ -1888,8 +1952,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( payload: { itemType, status: "inProgress", - title: event.data.toolName, - ...(event.data.arguments ? { data: event.data.arguments } : {}), + title: toolLifecycleTitle(toolMeta), + data: toolLifecycleData({ + toolCallId: event.data.toolCallId, + toolMeta, + ...(event.data.arguments ? { arguments: event.data.arguments } : {}), + }), }, }); return; @@ -1971,19 +2039,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( payload: { itemType: toolMeta?.itemType ?? "dynamic_tool_call", status: event.data.success ? "completed" : "failed", - title: toolMeta?.toolName ?? "tool", + title: toolLifecycleTitle(toolMeta), ...(detail ? { detail } : {}), - ...(event.data.toolTelemetry || event.data.result || event.data.error - ? { - data: { - ...(event.data.result ? { result: event.data.result } : {}), - ...(event.data.error ? { error: event.data.error } : {}), - ...(event.data.toolTelemetry - ? { toolTelemetry: event.data.toolTelemetry } - : {}), - }, - } - : {}), + data: toolLifecycleData({ + toolCallId: event.data.toolCallId, + toolMeta, + ...(event.data.result ? { result: event.data.result } : {}), + ...(event.data.error ? { error: event.data.error } : {}), + ...(event.data.toolTelemetry ? { toolTelemetry: event.data.toolTelemetry } : {}), + }), }, }); const toolItem: CopilotToolExecutionItem = { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..588e38d739b 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, undefined); + 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({ From f587c740aefe7c39d691291edcf14dd1a37bf9c7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 00:53:15 +0200 Subject: [PATCH 029/104] fix(web): stabilize context window snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/lib/contextWindow.test.ts | 56 ++++++++++++++++ apps/web/src/lib/contextWindow.ts | 92 +++++++++++++++++--------- 2 files changed, 115 insertions(+), 33 deletions(-) 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 { From 19e0789337bfa55a3adc23655bcbbfb09886cf3c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:01:54 +0200 Subject: [PATCH 030/104] Handle stale Copilot resume cursors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 48 ++++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 41 +++++++++++----- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index db901cf5f07..d69118696ab 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -56,7 +56,11 @@ const runtimeMock = vi.hoisted(() => { 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(), }; @@ -68,8 +72,10 @@ const runtimeMock = vi.hoisted(() => { 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; }, }; }); @@ -95,8 +101,12 @@ vi.mock("../copilotRuntime.ts", async () => { config, ); }), - resumeSession: vi.fn(async () => { - throw new Error("resumeSession is not used in CopilotAdapter tests"); + 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, ), @@ -253,6 +263,40 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("starts a fresh session when the persisted Copilot resume cursor is missing", () => + Effect.gen(function* () { + runtimeMock.state.resumeSessionImpl = async (sessionId) => { + throw new Error( + `Request session.resume failed with message: Session not found: ${sessionId}`, + ); + }; + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stale-resume-cursor"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { + schemaVersion: 1, + sessionId: "missing-copilot-session", + }, + }); + + assert.equal(runtimeMock.state.resumeSessionCalls.length, 1); + assert.equal(runtimeMock.state.resumeSessionCalls[0]?.sessionId, "missing-copilot-session"); + assert.equal(runtimeMock.state.createSessionConfigs.length, 1); + assert.equal(runtimeMock.state.createSessionConfigs[0]?.sessionId, threadId); + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: runtimeMock.state.lastSession.sessionId, + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("passes selected Copilot context tier when changing models for a turn", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index d4c09a7ee94..ee471f9c247 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -322,6 +322,11 @@ 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, @@ -2241,22 +2246,36 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( | "onEvent" >; - 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, - }); - } - return yield* copilotSdk.createSession(input.threadId, client, { + 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)), ); From b85b5546e56805c3a6cac22498db8e05ceaab7d7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:02:51 +0200 Subject: [PATCH 031/104] Emit Copilot file-change turn diffs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 93 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 80 +++++++++++++--- 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index d69118696ab..6e12e8b1673 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -621,6 +621,99 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits a turn diff update 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", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "edit the docs", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-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); + + let turnDiffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(turnDiffEvent?.type, "turn.diff.updated"); + if (turnDiffEvent?.type === "turn.diff.updated") { + assert.equal(turnDiffEvent.threadId, threadId); + assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); + assert.equal( + String(turnDiffEvent.itemId), + `copilot-tool-completion-${String(turn.turnId)}`, + ); + assert.deepStrictEqual(turnDiffEvent.payload, { + unifiedDiff: "", + }); + } + it.effect("emits command metadata separately from command output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index ee471f9c247..2f677608b39 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -150,8 +150,10 @@ interface CopilotSessionContext { readonly toolMetaById: Map; readonly turnIdByProviderItemId: Map; readonly emittedTextByItemId: Map; + readonly assistantItemIdByTurnId: Map; readonly pendingTaskCompletionTextByTurnId: Map; readonly turnIdsWithAssistantText: Set; + readonly turnDiffEmittedTurnIds: Set; readonly startedItemIds: Set; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; @@ -283,6 +285,32 @@ function toolOnlyCompletionText( return "Done. I completed the requested tool work."; } +function turnHasSuccessfulFileChange(context: CopilotSessionContext, turnId: TurnId): boolean { + const turn = context.turns.find((entry) => entry.id === turnId); + return ( + turn?.items.some( + (item): item is CopilotToolExecutionItem => + isCopilotToolExecutionItem(item) && item.success && item.itemType === "file_change", + ) ?? false + ); +} + +function assistantItemIdsForContext(context: CopilotSessionContext): Map { + const mutable = context as CopilotSessionContext & { + assistantItemIdByTurnId?: Map; + }; + mutable.assistantItemIdByTurnId ??= new Map(); + return mutable.assistantItemIdByTurnId; +} + +function turnDiffEmittedTurnIdsForContext(context: CopilotSessionContext): Set { + const mutable = context as CopilotSessionContext & { + turnDiffEmittedTurnIds?: Set; + }; + mutable.turnDiffEmittedTurnIds ??= new Set(); + return mutable.turnDiffEmittedTurnIds; +} + function processError( threadId: ThreadId, detail: string, @@ -1066,6 +1094,31 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + const emitTurnDiffUpdatedForFileChanges = async ( + context: CopilotSessionContext, + turnId: TurnId, + raw: SessionEvent, + ) => { + const turnDiffEmittedTurnIds = turnDiffEmittedTurnIdsForContext(context); + if (turnDiffEmittedTurnIds.has(turnId) || !turnHasSuccessfulFileChange(context, turnId)) { + return; + } + + turnDiffEmittedTurnIds.add(turnId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + itemId: assistantItemIdsForContext(context).get(turnId), + raw, + }), + type: "turn.diff.updated", + payload: { + unifiedDiff: "", + }, + }); + }; + const emitTextDelta = async (input: { readonly context: CopilotSessionContext; readonly turnId: TurnId; @@ -1100,6 +1153,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } if (input.itemType === "assistant_message" && input.streamKind === "assistant_text") { input.context.turnIdsWithAssistantText.add(input.turnId); + assistantItemIdsForContext(input.context).set(input.turnId, input.itemId); } await emitAsync({ ...createBaseEvent({ @@ -1147,6 +1201,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( messageId: itemId, content, }); + assistantItemIdsForContext(context).set(turnId, itemId); context.turnIdsWithAssistantText.add(turnId); }; @@ -1180,6 +1235,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( messageId: itemId, content, }); + assistantItemIdsForContext(context).set(turnId, itemId); context.turnIdsWithAssistantText.add(turnId); }; @@ -1578,19 +1634,16 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "session.idle": { if (context.activeTurnId) { + const turnId = context.activeTurnId; if (!event.data.aborted) { - await emitPendingTaskCompletionAsAssistantMessage(context, context.activeTurnId, event); - await emitToolOnlyCompletionAsAssistantMessage(context, context.activeTurnId, event); + await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); + await emitTurnDiffUpdatedForFileChanges(context, turnId, event); } - await emitTurnCompleted( - context, - context.activeTurnId, - event.data.aborted ? "cancelled" : "completed", - { - raw: event, - stopReason: event.data.aborted ? "aborted" : null, - }, - ); + await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { + raw: event, + stopReason: event.data.aborted ? "aborted" : null, + }); } updateProviderSession(context, { status: context.stopped ? "closed" : "ready", @@ -1784,6 +1837,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); + assistantItemIdsForContext(context).set(turnId, itemId); await emitTextDelta({ context, turnId, @@ -1806,6 +1860,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); + assistantItemIdsForContext(context).set(turnId, itemId); await emitTextDelta({ context, turnId, @@ -1867,6 +1922,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); + await emitTurnDiffUpdatedForFileChanges(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, stopReason: null, @@ -2311,8 +2367,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( toolMetaById: new Map(), turnIdByProviderItemId: new Map(), emittedTextByItemId: new Map(), + assistantItemIdByTurnId: new Map(), pendingTaskCompletionTextByTurnId: new Map(), turnIdsWithAssistantText: new Set(), + turnDiffEmittedTurnIds: new Set(), startedItemIds: new Set(), activeTurnId: undefined, activeSdkTurnId: undefined, From ab9d4721a9afba5a79e4bca67e14ea8ac4af622c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:33 +0200 Subject: [PATCH 032/104] Skip Copilot first-turn text generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderCommandReactor.test.ts | 67 +++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 41 ++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..1edf4942c33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -143,6 +143,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 +310,13 @@ describe("ProviderCommandReactor", () => { getInstanceInfo: (instanceId) => { const raw = String(instanceId); const driverKind = ProviderDriverKind.make( - raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, + raw.startsWith("claude") + ? "claudeAgent" + : raw.startsWith("codex") + ? "codex" + : raw.startsWith("copilot") + ? "copilot" + : raw, ); return Effect.succeed({ instanceId, @@ -368,7 +375,13 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerSettingsService.layerTest( + input?.textGenerationModelSelection !== undefined + ? { textGenerationModelSelection: input.textGenerationModelSelection } + : {}, + ), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -516,6 +529,52 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + it("skips Copilot title generation 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).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe(seededTitle); + }); + 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"; @@ -2057,7 +2116,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 +2135,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..da33f568946 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -87,6 +87,7 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +const COPILOT_PROVIDER = ProviderDriverKind.make("copilot"); export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -214,6 +215,26 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); + const shouldSkipCopilotFirstTurnTextGeneration = Effect.fnUntraced(function* (input: { + readonly threadModelSelection: ModelSelection; + readonly textGenerationModelSelection: ModelSelection; + }) { + const [threadProvider, textGenerationProvider] = yield* Effect.all( + [ + providerService.getInstanceInfo(input.threadModelSelection.instanceId).pipe(Effect.option), + providerService + .getInstanceInfo(input.textGenerationModelSelection.instanceId) + .pipe(Effect.option), + ], + { concurrency: "unbounded" }, + ); + + return ( + Option.getOrUndefined(threadProvider)?.driverKind === COPILOT_PROVIDER && + Option.getOrUndefined(textGenerationProvider)?.driverKind === COPILOT_PROVIDER + ); + }); + const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; readonly kind: @@ -648,6 +669,7 @@ const make = Effect.gen(function* () { "maybeGenerateAndRenameWorktreeBranchForFirstTurn", )(function* (input: { readonly threadId: ThreadId; + readonly threadModelSelection: ModelSelection; readonly branch: string | null; readonly worktreePath: string | null; readonly messageText: string; @@ -666,6 +688,14 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + if ( + yield* shouldSkipCopilotFirstTurnTextGeneration({ + threadModelSelection: input.threadModelSelection, + textGenerationModelSelection: modelSelection, + }) + ) { + return; + } const generated = yield* textGeneration.generateBranchName({ cwd, @@ -702,6 +732,7 @@ const make = Effect.gen(function* () { const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( function* (input: { readonly threadId: ThreadId; + readonly threadModelSelection: ModelSelection; readonly cwd: string; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -711,6 +742,14 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + if ( + yield* shouldSkipCopilotFirstTurnTextGeneration({ + threadModelSelection: input.threadModelSelection, + textGenerationModelSelection: modelSelection, + }) + ) { + return; + } const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, @@ -787,6 +826,7 @@ const make = Effect.gen(function* () { yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ threadId: event.payload.threadId, + threadModelSelection: thread.modelSelection, branch: thread.branch, worktreePath: thread.worktreePath, ...generationInput, @@ -795,6 +835,7 @@ const make = Effect.gen(function* () { if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, + threadModelSelection: thread.modelSelection, cwd: generationCwd, ...generationInput, }).pipe(Effect.forkScoped); From 40afdf1b8d9f443378e04777f918d2fc300beafb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:49 +0200 Subject: [PATCH 033/104] Propagate thread metadata shell updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 55 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 6 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 6e12e8b1673..08adf6a48f3 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -714,6 +714,61 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); } + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits thread metadata updates from Copilot title changes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-title-change"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-title-change", + timestamp, + parentId: null, + type: "session.title_changed", + data: { + title: "Implement Copilot thread titles", + }, + } as SessionEvent); + + let titleEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && titleEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + titleEvent = runtimeEvents.find((event) => event.type === "thread.metadata.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(titleEvent?.type, "thread.metadata.updated"); + if (titleEvent?.type === "thread.metadata.updated") { + assert.equal(titleEvent.threadId, threadId); + assert.deepStrictEqual(titleEvent.payload, { + name: "Implement Copilot thread titles", + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits command metadata separately from command output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 2f677608b39..39e7ace5e5f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1663,6 +1663,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } case "session.title_changed": { + const title = trimOrUndefined(event.data.title); + if (!title) { + return; + } await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -1670,7 +1674,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "thread.metadata.updated", payload: { - name: event.data.title.trim(), + name: title, }, }); return; From 23532c4351f3a4a92ae035ab2904249354240366 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:57 +0200 Subject: [PATCH 034/104] Warn on Copilot mode sync failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/provider/Layers/CopilotAdapter.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 39e7ace5e5f..6277b682723 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1432,7 +1432,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const syncSessionMode = ( context: CopilotSessionContext, mode: CopilotMode, - ): Effect.Effect => + ): Effect.Effect => copilotSdk.setMode(context, mode).pipe( Effect.flatMap(() => emit({ @@ -1447,6 +1447,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, }), ), + 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 = ( @@ -2388,19 +2400,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( requestedCopilotMode({ runtimeMode: input.runtimeMode, }), - ).pipe( - Effect.catch((cause) => - emit({ - ...createBaseEvent({ - threadId: input.threadId, - }), - type: "runtime.warning", - payload: { - message: "Failed to synchronize Copilot mode with the requested runtime mode.", - detail: cause, - }, - }), - ), ); for (const event of earlyEvents) { From 211f2f373e07a2d5a1ab1f8584b302c14cdcae2b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 09:58:47 +0200 Subject: [PATCH 035/104] Fix Copilot approval resolution Emit request.resolved from respondToRequest before clearing the pending permission binding so projections clear pending approvals even when the SDK permission.completed event arrives later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 38 +++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 47 +++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 08adf6a48f3..e872ab99334 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -342,6 +342,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); assert.ok(config.onPermissionRequest); @@ -400,11 +407,42 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }); + let requestResolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && requestResolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + requestResolved = runtimeEvents.find( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + } + assert.equal(requestResolved?.type, "request.resolved"); + if (requestResolved?.type === "request.resolved") { + assert.equal(requestResolved.payload.requestType, "command_execution_approval"); + assert.equal(requestResolved.payload.decision, "acceptForSession"); + assert.deepStrictEqual(requestResolved.payload.resolution, result); + } + + config.onEvent({ + id: "evt-copilot-permission-session-approval-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + const resolvedEvents = runtimeEvents.filter( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + assert.equal(resolvedEvents.length, 1); + const duplicateReply = yield* Effect.flip( adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "acceptForSession"), ); assert.match(duplicateReply.message, /Unknown pending permission request/); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6277b682723..3211daaa6bc 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -13,6 +13,7 @@ import type { import { EventId, type CopilotSettings, + type ProviderApprovalDecision, ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, @@ -1269,6 +1270,27 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, }); + const emitPermissionRequestResolved = ( + context: CopilotSessionContext, + pending: PendingPermissionBinding, + decision: ProviderApprovalDecision | PermissionRequestResult["kind"], + resolution: unknown, + raw?: SessionEvent, + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: pending.requestId, + raw, + }), + type: "request.resolved", + payload: { + requestType: pending.requestType, + decision, + resolution, + }, + }); + const emitUserInputRequested = ( context: CopilotSessionContext, requestId: string, @@ -2158,19 +2180,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } context.pendingPermissionBindings.delete(event.data.requestId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - requestId: binding.requestId, - raw: event, - }), - type: "request.resolved", - payload: { - requestType: binding.requestType, - decision: event.data.result.kind, - resolution: event.data.result, - }, - }); + await runWithContext( + emitPermissionRequestResolved( + context, + binding, + event.data.result.kind, + event.data.result, + event, + ), + ); return; } case "user_input.requested": { @@ -2599,8 +2617,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : decision === "acceptForSession" ? APPROVED_PERMISSION_RESULT : DENIED_PERMISSION_RESULT; - yield* Deferred.succeed(binding.deferred, result); + yield* emitPermissionRequestResolved(context, binding, decision, result); context.pendingPermissionBindings.delete(requestId); + yield* Deferred.succeed(binding.deferred, result); }, ); From 7eae1b53c19e1c6771f08682e5c8c207f14c6cc1 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 10:50:34 +0200 Subject: [PATCH 036/104] Ensure Copilot task completions snapshot diffs Trigger the diff snapshot path when Copilot reports a successful Task_complete tool result, so completed Copilot tasks capture workspace changes even without an explicit file-change tool event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 27 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 22 ++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index e872ab99334..4d0a98675a6 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -467,6 +467,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { attachments: [], }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); @@ -551,6 +558,26 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { content: resultText, }); + let turnDiffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(turnDiffEvent?.type, "turn.diff.updated"); + if (turnDiffEvent?.type === "turn.diff.updated") { + assert.equal(turnDiffEvent.threadId, threadId); + assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); + assert.equal( + String(turnDiffEvent.itemId), + `copilot-task-completion-${String(turn.turnId)}`, + ); + assert.deepStrictEqual(turnDiffEvent.payload, { + unifiedDiff: "", + }); + } + yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 3211daaa6bc..80b6b5e5537 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -296,6 +296,16 @@ function turnHasSuccessfulFileChange(context: CopilotSessionContext, turnId: Tur ); } +function turnHasSuccessfulTaskComplete(context: CopilotSessionContext, turnId: TurnId): boolean { + const turn = context.turns.find((entry) => entry.id === turnId); + return ( + turn?.items.some( + (item): item is CopilotToolExecutionItem => + isCopilotToolExecutionItem(item) && item.success && isTaskCompleteTool(item.toolName), + ) ?? false + ); +} + function assistantItemIdsForContext(context: CopilotSessionContext): Map { const mutable = context as CopilotSessionContext & { assistantItemIdByTurnId?: Map; @@ -1095,13 +1105,17 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - const emitTurnDiffUpdatedForFileChanges = async ( + const emitTurnDiffUpdatedForSnapshotCandidate = async ( context: CopilotSessionContext, turnId: TurnId, raw: SessionEvent, ) => { const turnDiffEmittedTurnIds = turnDiffEmittedTurnIdsForContext(context); - if (turnDiffEmittedTurnIds.has(turnId) || !turnHasSuccessfulFileChange(context, turnId)) { + if ( + turnDiffEmittedTurnIds.has(turnId) || + (!turnHasSuccessfulFileChange(context, turnId) && + !turnHasSuccessfulTaskComplete(context, turnId)) + ) { return; } @@ -1672,7 +1686,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!event.data.aborted) { await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); - await emitTurnDiffUpdatedForFileChanges(context, turnId, event); + await emitTurnDiffUpdatedForSnapshotCandidate(context, turnId, event); } await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { raw: event, @@ -1960,7 +1974,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); - await emitTurnDiffUpdatedForFileChanges(context, turnId, event); + await emitTurnDiffUpdatedForSnapshotCandidate(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, stopReason: null, From de10f7e73da489749b6000917eaa0ff7a360f36e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 10 Jun 2026 08:49:00 +0200 Subject: [PATCH 037/104] Fix first-turn title generation draining Generate titles during Copilot first turns, retry transient title generation failures, and ensure drain waits for first-turn auxiliary work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderCommandReactor.test.ts | 171 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 69 +++---- 2 files changed, 204 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 1edf4942c33..5ebc2e3a59c 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"; @@ -529,7 +530,118 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); - it("skips Copilot title generation while starting the first Copilot turn", async () => { + 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, @@ -569,10 +681,10 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); await harness.drain(); - expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + 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(seededTitle); + expect(thread?.title).toBe("Generated title"); }); it("does not overwrite an existing custom thread title on the first turn", async () => { @@ -666,6 +778,59 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Reconnect spinner resume bug"); }); + it("replaces provider-expanded first prompt titles that match the truncated client seed", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const fullPromptTitle = + "Please investigate reconnect failures after restarting the session and make the startup path reliable."; + const seededTitle = "Please investigate reconnect failures after restar..."; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ + title: "Reconnect startup reliability", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-provider-expanded-prompt"), + threadId: ThreadId.make("thread-1"), + title: fullPromptTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-provider-expanded-prompt"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-provider-expanded-prompt"), + role: "user", + text: fullPromptTitle, + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "Reconnect startup reliability" + ); + }); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Reconnect startup reliability"); + }); + it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index da33f568946..be0d5358ff3 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"; @@ -111,9 +112,13 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea } const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; + if (trimmedTitleSeed === undefined || trimmedTitleSeed.length === 0) { + return false; + } + + return ( + trimmedCurrentTitle === trimmedTitleSeed || truncate(trimmedCurrentTitle) === trimmedTitleSeed + ); } function findProviderAdapterRequestError( @@ -215,7 +220,7 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); - const shouldSkipCopilotFirstTurnTextGeneration = Effect.fnUntraced(function* (input: { + const shouldSkipCopilotFirstTurnBranchGeneration = Effect.fnUntraced(function* (input: { readonly threadModelSelection: ModelSelection; readonly textGenerationModelSelection: ModelSelection; }) { @@ -689,7 +694,7 @@ const make = Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; if ( - yield* shouldSkipCopilotFirstTurnTextGeneration({ + yield* shouldSkipCopilotFirstTurnBranchGeneration({ threadModelSelection: input.threadModelSelection, textGenerationModelSelection: modelSelection, }) @@ -742,21 +747,14 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - if ( - yield* shouldSkipCopilotFirstTurnTextGeneration({ - threadModelSelection: input.threadModelSelection, - textGenerationModelSelection: modelSelection, - }) - ) { - return; - } - - 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); @@ -824,22 +822,26 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - threadModelSelection: thread.modelSelection, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ + yield* firstTurnAuxiliaryWorker.enqueue( + maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + threadModelSelection: thread.modelSelection, + cwd: generationCwd, + ...generationInput, + }), + ); + } + + yield* firstTurnAuxiliaryWorker.enqueue( + maybeGenerateAndRenameWorktreeBranchForFirstTurn({ threadId: event.payload.threadId, threadModelSelection: thread.modelSelection, - cwd: generationCwd, + branch: thread.branch, + worktreePath: thread.worktreePath, ...generationInput, - }).pipe(Effect.forkScoped); - } + }), + ); } const handleTurnStartFailure = (cause: Cause.Cause) => { @@ -1099,6 +1101,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* () { @@ -1122,7 +1125,7 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: worker.drain.pipe(Effect.andThen(firstTurnAuxiliaryWorker.drain), Effect.asVoid), } satisfies ProviderCommandReactorShape; }); From 8c16cdd3b44b23a77637dc6f8ae0ffaf5c5a2f5a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:08:54 +0200 Subject: [PATCH 038/104] Align Copilot provider format --- .../src/provider/Drivers/CopilotDriver.ts | 25 ++++++++++++++++--- .../provider/Layers/CopilotAdapter.test.ts | 20 ++++++++++----- .../src/provider/Layers/CopilotAdapter.ts | 23 +++-------------- .../src/provider/Services/CopilotAdapter.ts | 20 ++++++++++----- apps/web/src/session-logic.test.ts | 2 +- 5 files changed, 53 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index d5c11e7698c..05bbd60ac77 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -1,3 +1,16 @@ +/** + * CopilotDriver — `ProviderDriver` for the GitHub Copilot SDK runtime. + * + * Mirrors the other provider drivers: a plain value whose `create()` returns + * one `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration` + * closures captured over the per-instance `CopilotSettings`. + * + * Each instance owns an isolated Copilot home directory under server state and + * a per-instance SDK client, so sessions and persisted cursors do not leak + * across configured Copilot providers. + * + * @module provider/Drivers/CopilotDriver + */ import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, Path, Schema, Stream } from "effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,7 +25,11 @@ import { } from "../Layers/CopilotProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { type ProviderDriver, type ProviderInstance } from "../ProviderDriver.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"; @@ -60,10 +77,10 @@ export const CopilotDriver: ProviderDriver = const processEnv = mergeProviderInstanceEnvironment(environment); const effectiveConfig = { ...config, enabled } satisfies CopilotSettings; const baseDirectory = path.join(serverConfig.stateDir, "providers", "copilot", instanceId); - const continuationIdentity = { + const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, - continuationKey: `copilot:home:${baseDirectory}`, - }; + instanceId, + }); const stampIdentity = withInstanceIdentity({ instanceId, displayName, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 4d0a98675a6..5bba1d08edb 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -10,11 +10,12 @@ import type { SessionEvent, } from "@github/copilot-sdk"; import { it } from "@effect/vitest"; -import { DateTime, Effect, Fiber, Layer, Stream } from "effect"; +import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; import { beforeEach, vi } from "vitest"; import { ApprovalRequestId, + CopilotSettings, type ProviderRuntimeEvent, ProviderDriverKind, ProviderInstanceId, @@ -22,10 +23,15 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import type { CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { makeCopilotAdapterLive } from "./CopilotAdapter.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"); @@ -131,13 +137,15 @@ const nativeEventLogger = { close: vi.fn(() => Effect.void), } satisfies EventNdjsonLogger; -const CopilotAdapterTestLayer = makeCopilotAdapterLive({ nativeEventLogger }).pipe( +const CopilotAdapterTestLayer = Layer.effect( + CopilotAdapter, + makeCopilotAdapter(decodeCopilotSettings({}), { nativeEventLogger }), +).pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-copilot-adapter-test-", }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 80b6b5e5537..7601249da34 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -29,11 +29,10 @@ import { type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { DateTime, Deferred, Effect, Layer, Path, Predicate, PubSub, Stream } from "effect"; +import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -41,7 +40,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { createCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -61,7 +60,7 @@ type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permiss type SessionApprovalDecision = Extract; type SessionApproval = NonNullable; -interface CopilotAdapterLiveOptions { +export interface CopilotAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; readonly baseDirectory?: string; @@ -2773,19 +2772,3 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, } satisfies CopilotAdapterShape; }); - -export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { - return Layer.effect( - CopilotAdapter, - Effect.gen(function* () { - const serverSettingsService = yield* ServerSettingsService; - const settings = yield* Effect.map( - serverSettingsService.getSettings, - (serverSettings) => serverSettings.providers.copilot, - ).pipe(Effect.orDie); - return yield* makeCopilotAdapter(settings, options); - }), - ); -} - -export const CopilotAdapterLive = makeCopilotAdapterLive(); diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts index 24255ef2a70..e203a7e230f 100644 --- a/apps/server/src/provider/Services/CopilotAdapter.ts +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -1,10 +1,18 @@ -import { Context } from "effect"; - +/** + * 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 {} - -export class CopilotAdapter extends Context.Service()( - "t3/provider/Services/CopilotAdapter", -) {} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 588e38d739b..4a1b5ae13b8 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1142,7 +1142,7 @@ describe("deriveWorkLogEntries", () => { }), ]; - const [entry] = deriveWorkLogEntries(activities, undefined); + const [entry] = deriveWorkLogEntries(activities); expect(entry).toMatchObject({ command: "git status --short", detail: "M apps/server/src/provider/Layers/CopilotAdapter.ts", From ab3c226f9ffc35f9af742c57ad20800a03e87d04 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:47:04 +0200 Subject: [PATCH 039/104] Wire Copilot tasks into task list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 103 +++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 104 ++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5bba1d08edb..adb86b8bd92 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -49,6 +49,13 @@ const runtimeMock = vi.hoisted(() => { 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), @@ -842,6 +849,102 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits Copilot background tasks as task list plan updates", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-tasks-plan"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [ + { + type: "agent", + id: "task-explore-1", + toolCallId: "tool-task-explore-1", + description: "Exploring provider events", + status: "running", + startedAt: "2026-06-11T12:00:00.000Z", + agentType: "explore", + prompt: "Find Copilot task events", + }, + { + type: "shell", + id: "task-shell-1", + description: "Running tests", + status: "completed", + startedAt: "2026-06-11T12:00:01.000Z", + command: "vp test", + attachmentMode: "detached", + }, + { + type: "agent", + id: "task-review-1", + toolCallId: "tool-task-review-1", + description: "Reviewing implementation", + status: "failed", + startedAt: "2026-06-11T12:00:02.000Z", + agentType: "code-review", + prompt: "Review the implementation", + }, + ], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-background-tasks", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + + let planEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && planEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + planEvent = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(planEvent?.type, "turn.plan.updated"); + if (planEvent?.type === "turn.plan.updated") { + assert.equal(planEvent.threadId, threadId); + assert.equal(String(planEvent.turnId), String(turn.turnId)); + assert.deepStrictEqual(planEvent.payload, { + explanation: "Copilot Tasks", + plan: [ + { step: "Exploring provider events", status: "inProgress" }, + { step: "Running tests", status: "completed" }, + { step: "Reviewing implementation (failed)", status: "pending" }, + ], + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits command metadata separately from command output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 7601249da34..e7dc1d1cf86 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -59,6 +59,24 @@ type SessionUserInputRequestedEvent = Extract; type SessionApproval = NonNullable; +type CopilotTaskStatus = "running" | "idle" | "completed" | "failed" | "cancelled"; +type CopilotTaskInfo = { + readonly description: string; + readonly status: CopilotTaskStatus; +}; +type CopilotTaskList = { + readonly tasks: ReadonlyArray; +}; +type CopilotBackgroundTasksRpc = { + readonly backgroundTasks?: { + readonly list?: () => Promise; + }; +}; + +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; export interface CopilotAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; @@ -640,6 +658,51 @@ function isTaskCompleteTool(toolName: string | undefined): boolean { return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "taskcomplete"; } +function copilotBackgroundTasksList(session: CopilotSession): () => Promise { + const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks + ?.list; + if (!list) { + throw new Error("Copilot runtime does not expose background task listing."); + } + return list; +} + +function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status"] { + switch (status) { + case "completed": + return "completed"; + case "running": + case "idle": + return "inProgress"; + case "failed": + case "cancelled": + return "pending"; + } +} + +function copilotTaskStatusSuffix(status: CopilotTaskStatus): string { + switch (status) { + case "failed": + return " (failed)"; + case "cancelled": + return " (cancelled)"; + case "running": + case "idle": + case "completed": + return ""; + } +} + +function planStepsFromCopilotTasks(tasks: ReadonlyArray): PlanStep[] { + return tasks.map((task) => { + const description = trimOrUndefined(task.description) ?? "Task"; + return { + step: `${description}${copilotTaskStatusSuffix(task.status)}`, + status: normalizeCopilotTaskStatus(task.status), + }; + }); +} + function toolStreamKind( itemType: ToolMeta["itemType"] | undefined, ): "command_output" | "file_change_output" | "unknown" { @@ -968,6 +1031,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( cause, }), }), + readBackgroundTasks: ( + context: CopilotSessionContext, + ): Effect.Effect => + Effect.tryPromise({ + try: () => copilotBackgroundTasksList(context.sdkSession)(), + 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, @@ -1419,6 +1495,30 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } }); + 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); + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.plan.updated", + payload: { + explanation: "Copilot Tasks", + plan: planStepsFromCopilotTasks(taskList.tasks), + }, + }); + }); + const onPermissionRequest = ( context: CopilotSessionContext, request: PermissionRequest, @@ -1797,6 +1897,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); return; } + case "session.background_tasks_changed": { + await runWithContext(emitBackgroundTasksPlanSnapshot(context, event)); + return; + } case "assistant.turn_start": { const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); updateProviderSession(context, { From fa0fd1de607de73513658ed95d4054a831c70bbd Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:57:54 +0200 Subject: [PATCH 040/104] Handle missing Copilot background task list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 76 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 14 ++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index adb86b8bd92..9decf70d462 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -945,6 +945,82 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("ignores background task change events when Copilot cannot list tasks", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-tasks-missing-list"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const session = runtimeMock.state.lastSession as unknown as { + rpc: { backgroundTasks?: unknown }; + }; + delete session.rpc.backgroundTasks; + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-background-tasks-without-list", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-background-tasks-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + assert.equal( + runtimeEvents.find((event) => event.type === "runtime.error"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits command metadata separately from command output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index e7dc1d1cf86..b200360911a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -658,12 +658,11 @@ function isTaskCompleteTool(toolName: string | undefined): boolean { return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "taskcomplete"; } -function copilotBackgroundTasksList(session: CopilotSession): () => Promise { +function copilotBackgroundTasksList( + session: CopilotSession, +): (() => Promise) | undefined { const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks ?.list; - if (!list) { - throw new Error("Copilot runtime does not expose background task listing."); - } return list; } @@ -1033,9 +1032,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), readBackgroundTasks: ( context: CopilotSessionContext, - ): Effect.Effect => + ): Effect.Effect => Effect.tryPromise({ - try: () => copilotBackgroundTasksList(context.sdkSession)(), + try: () => copilotBackgroundTasksList(context.sdkSession)?.() ?? Promise.resolve(undefined), catch: (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, @@ -1505,6 +1504,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } const taskList = yield* copilotSdk.readBackgroundTasks(context); + if (!taskList) { + return; + } yield* emit({ ...createBaseEvent({ threadId: context.threadId, From a6c465ddc01751027d6c219b54b6878b3c8ea668 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:07:30 +0200 Subject: [PATCH 041/104] Fix Copilot diff markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 128 ++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 141 ++++++++++++++++-- 2 files changed, 260 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 9decf70d462..643707746c2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -798,6 +798,134 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits a turn diff update when a Copilot write permission is approved", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-write-permission-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "update the README", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "permission-write-readme"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-write-readme", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as PermissionRequest; + + emit({ + id: "evt-copilot-write-permission-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + emit({ + id: "evt-copilot-write-permission-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + assert.equal(opened?.type, "request.opened"); + if (opened?.type === "request.opened") { + assert.equal(opened.payload.requestType, "file_change_approval"); + assert.equal(String(opened.turnId), String(turn.turnId)); + } + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const approvalResult = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + + emit({ + id: "evt-copilot-write-permission-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result: approvalResult, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-write-permission-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + let turnDiffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(turnDiffEvent?.type, "turn.diff.updated"); + if (turnDiffEvent?.type === "turn.diff.updated") { + assert.equal(turnDiffEvent.threadId, threadId); + assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); + assert.equal( + String(turnDiffEvent.itemId), + `copilot-tool-completion-${String(turn.turnId)}`, + ); + assert.deepStrictEqual(turnDiffEvent.payload, { + unifiedDiff: "", + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits thread metadata updates from Copilot title changes", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b200360911a..1d3c0afc577 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -107,6 +107,7 @@ interface PendingPermissionBinding { | "command_execution_approval" | "file_read_approval" | "file_change_approval"; + readonly turnId?: TurnId | undefined; readonly permissionRequest: SessionPermissionRequest; readonly promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined; readonly deferred: Deferred.Deferred; @@ -171,6 +172,7 @@ interface CopilotSessionContext { readonly assistantItemIdByTurnId: Map; readonly pendingTaskCompletionTextByTurnId: Map; readonly turnIdsWithAssistantText: Set; + readonly turnIdsWithFileChangeEvidence: Set; readonly turnDiffEmittedTurnIds: Set; readonly startedItemIds: Set; activeTurnId: TurnId | undefined; @@ -287,6 +289,9 @@ function toolOnlyCompletionText( (item): item is CopilotToolExecutionItem => isCopilotToolExecutionItem(item) && item.success, ) ?? []; if (completedTools.length === 0) { + if (context.turnIdsWithFileChangeEvidence.has(turnId)) { + return "Done. I completed the requested file changes."; + } return undefined; } @@ -304,6 +309,10 @@ function toolOnlyCompletionText( } function turnHasSuccessfulFileChange(context: CopilotSessionContext, turnId: TurnId): boolean { + if (context.turnIdsWithFileChangeEvidence.has(turnId)) { + return true; + } + const turn = context.turns.find((entry) => entry.id === turnId); return ( turn?.items.some( @@ -339,6 +348,10 @@ function turnDiffEmittedTurnIdsForContext(context: CopilotSessionContext): Set { + const value = args[key]; + return typeof value === "string" && trimOrUndefined(value) !== undefined; + }); + const hasEditPayload = editPayloadKeys.some((key) => args[key] !== undefined); + return hasFilePath && hasEditPayload; +} + +function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("replace") + ) { + return true; + } + + if ( + normalized.includes("create") || + normalized.includes("delete") || + normalized.includes("remove") || + normalized.includes("modify") || + normalized.includes("update") || + normalized.includes("insert") + ) { + return normalized.includes("file") || toolArgumentsLookLikeFileChange(arguments_); + } + + return false; +} + +function toolItemType( + toolName: string, + mcpServerName?: string, + arguments_?: unknown, +): ToolMeta["itemType"] { const normalized = toolName.toLowerCase(); if (mcpServerName) { return "mcp_tool_call"; @@ -629,12 +718,7 @@ function toolItemType(toolName: string, mcpServerName?: string): ToolMeta["itemT ) { return "command_execution"; } - if ( - normalized.includes("write") || - normalized.includes("edit") || - normalized.includes("patch") || - normalized.includes("replace") - ) { + if (toolNameImpliesFileChange(toolName, arguments_)) { return "file_change"; } if (normalized.includes("search") || normalized.includes("fetch") || normalized.includes("web")) { @@ -1336,6 +1420,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( emit({ ...createBaseEvent({ threadId: context.threadId, + turnId: pending.turnId, requestId: pending.requestId, raw: { ...({ @@ -1368,6 +1453,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( emit({ ...createBaseEvent({ threadId: context.threadId, + turnId: pending.turnId, requestId: pending.requestId, raw, }), @@ -1426,9 +1512,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, @@ -2144,7 +2235,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } const itemId = `copilot-tool-${event.data.toolCallId}`; - const itemType = toolItemType(event.data.toolName, event.data.mcpServerName); + const itemType = toolItemType( + event.data.toolName, + event.data.mcpServerName, + event.data.arguments, + ); const command = itemType === "command_execution" ? commandFromToolArguments(event.data.arguments) @@ -2241,7 +2336,17 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } const itemId = `copilot-tool-${event.data.toolCallId}`; - const toolMeta = context.toolMetaById.get(event.data.toolCallId); + const eventData = stringRecord(event.data); + const eventToolName = trimOrUndefined( + typeof eventData?.toolName === "string" ? eventData.toolName : undefined, + ); + const fallbackToolMeta: ToolMeta | undefined = eventToolName + ? { + toolName: eventToolName, + itemType: toolItemType(eventToolName, undefined, eventData?.arguments), + } + : undefined; + const toolMeta = context.toolMetaById.get(event.data.toolCallId) ?? fallbackToolMeta; const detail = trimOrUndefined(event.data.result?.detailedContent) ?? trimOrUndefined(event.data.result?.content) ?? @@ -2277,6 +2382,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...(detail ? { detail } : {}), }; appendTurnItem(context, turnId, toolItem); + if (event.data.success && toolMeta?.itemType === "file_change") { + markTurnHasFileChangeEvidence(context, turnId); + } if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { context.pendingTaskCompletionTextByTurnId.set(turnId, detail); } @@ -2299,6 +2407,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } context.pendingPermissionBindings.delete(event.data.requestId); + if ( + binding.requestType === "file_change_approval" && + binding.turnId && + permissionResultApproves(event.data.result) + ) { + markTurnHasFileChangeEvidence(context, binding.turnId); + } await runWithContext( emitPermissionRequestResolved( context, @@ -2523,6 +2638,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( assistantItemIdByTurnId: new Map(), pendingTaskCompletionTextByTurnId: new Map(), turnIdsWithAssistantText: new Set(), + turnIdsWithFileChangeEvidence: new Set(), turnDiffEmittedTurnIds: new Set(), startedItemIds: new Set(), activeTurnId: undefined, @@ -2738,6 +2854,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : DENIED_PERMISSION_RESULT; yield* emitPermissionRequestResolved(context, binding, decision, result); context.pendingPermissionBindings.delete(requestId); + if ( + binding.requestType === "file_change_approval" && + binding.turnId && + permissionResultApproves(result) + ) { + markTurnHasFileChangeEvidence(context, binding.turnId); + } yield* Deferred.succeed(binding.deferred, result); }, ); From 7adc06262578da677e9296e50a7068ad5da860b2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:15:19 +0200 Subject: [PATCH 042/104] Filter Copilot command-only completion fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 104 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 6 +- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 643707746c2..b77c03941b3 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -701,6 +701,110 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("does not render the command-only completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-only-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run the tests", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-only-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command-only", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-run-tests", + toolName: "bash", + arguments: { + command: "vp test", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-run-tests", + success: true, + result: { + content: "All tests passed.", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-only-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command-only", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits a turn diff update when a Copilot file-change turn completes", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 1d3c0afc577..6cbe84d4c61 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -299,12 +299,12 @@ function toolOnlyCompletionText( if (itemTypes.has("file_change")) { return "Done. I completed the requested file changes."; } - if (itemTypes.has("command_execution")) { - return "Done. I ran the requested commands."; - } if (itemTypes.has("collab_agent_tool_call")) { return "Done. I completed the requested task."; } + if (itemTypes.size === 1 && itemTypes.has("command_execution")) { + return undefined; + } return "Done. I completed the requested tool work."; } From 5bcd36d3f384676af5f71c982e10133a5eee227d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:20:11 +0200 Subject: [PATCH 043/104] Filter Copilot task-agent completion fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 113 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 10 +- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index b77c03941b3..24347ffe61a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -805,6 +805,119 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("does not render the task-agent completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-task-agent-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "delegate this task", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-task-agent-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-task-agent", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-agent-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-task-agent", + toolName: "Task", + arguments: { + description: "delegate the task", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-agent-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-task-agent", + success: true, + result: { + content: "✓ Task completed: Updated the implementation.", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-task-agent-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-task-agent", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.ok( + runtimeEvents.some( + (event) => + event.type === "item.completed" && + event.payload.itemType === "collab_agent_tool_call" && + event.payload.detail === "✓ Task completed: Updated the implementation.", + ), + ); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits a turn diff update when a Copilot file-change turn completes", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6cbe84d4c61..5521f2b11e7 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -299,10 +299,12 @@ function toolOnlyCompletionText( if (itemTypes.has("file_change")) { return "Done. I completed the requested file changes."; } - if (itemTypes.has("collab_agent_tool_call")) { - return "Done. I completed the requested task."; - } - if (itemTypes.size === 1 && itemTypes.has("command_execution")) { + if ( + itemTypes.size > 0 && + [...itemTypes].every( + (itemType) => itemType === "command_execution" || itemType === "collab_agent_tool_call", + ) + ) { return undefined; } return "Done. I completed the requested tool work."; From d6369196b75722b2446b8a736761951005a7e748 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:23:43 +0200 Subject: [PATCH 044/104] Filter Copilot task-completed generic fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 26 +++++++++---------- .../src/provider/Layers/CopilotAdapter.ts | 8 ++++++ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 24347ffe61a..a01c4451ddf 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -805,10 +805,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("does not render the task-agent completion fallback as assistant text", () => + 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-task-agent-fallback-filter"); + const threadId = asThreadId("copilot-generic-fallback-task-completed-filter"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -836,34 +836,34 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const timestamp = yield* nowIso; emit({ - id: "evt-copilot-task-agent-turn-start", + id: "evt-copilot-generic-task-completed-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-task-agent", + turnId: "sdk-turn-generic-task-completed", }, } as SessionEvent); emit({ - id: "evt-copilot-task-agent-start", + id: "evt-copilot-generic-task-completed-start", timestamp, parentId: null, type: "tool.execution_start", data: { - toolCallId: "tool-task-agent", - toolName: "Task", + toolCallId: "tool-finish-work", + toolName: "finish_work", arguments: { - description: "delegate the task", + description: "finish the work", }, }, } as SessionEvent); emit({ - id: "evt-copilot-task-agent-complete", + id: "evt-copilot-generic-task-completed-complete", timestamp, parentId: null, type: "tool.execution_complete", data: { - toolCallId: "tool-task-agent", + toolCallId: "tool-finish-work", success: true, result: { content: "✓ Task completed: Updated the implementation.", @@ -871,12 +871,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } as SessionEvent); emit({ - id: "evt-copilot-task-agent-turn-end", + id: "evt-copilot-generic-task-completed-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-task-agent", + turnId: "sdk-turn-generic-task-completed", }, } as SessionEvent); @@ -897,7 +897,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeEvents.some( (event) => event.type === "item.completed" && - event.payload.itemType === "collab_agent_tool_call" && + event.payload.itemType === "dynamic_tool_call" && event.payload.detail === "✓ Task completed: Updated the implementation.", ), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 5521f2b11e7..8f478d86bb1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -275,6 +275,10 @@ function isCopilotToolExecutionItem(item: unknown): item is CopilotToolExecution ); } +function isTaskCompletedDetail(detail: string | undefined): boolean { + return detail?.trim().startsWith("✓ Task completed:") ?? false; +} + function toolOnlyCompletionText( context: CopilotSessionContext, turnId: TurnId, @@ -295,6 +299,10 @@ function toolOnlyCompletionText( return undefined; } + if (completedTools.some((item) => isTaskCompletedDetail(item.detail))) { + return undefined; + } + const itemTypes = new Set(completedTools.map((item) => item.itemType).filter(Boolean)); if (itemTypes.has("file_change")) { return "Done. I completed the requested file changes."; From c21528d816dad7a59e8be17b6c7e21f881179e89 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:20:46 +0200 Subject: [PATCH 045/104] Stop emitting empty Copilot diffs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 61 +++-------------- .../src/provider/Layers/CopilotAdapter.ts | 65 ------------------- 2 files changed, 8 insertions(+), 118 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index a01c4451ddf..ca12a7fa179 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -573,25 +573,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { content: resultText, }); - let turnDiffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } + yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(turnDiffEvent?.type, "turn.diff.updated"); - if (turnDiffEvent?.type === "turn.diff.updated") { - assert.equal(turnDiffEvent.threadId, threadId); - assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); - assert.equal( - String(turnDiffEvent.itemId), - `copilot-task-completion-${String(turn.turnId)}`, - ); - assert.deepStrictEqual(turnDiffEvent.payload, { - unifiedDiff: "", - }); - } + assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); yield* adapter.stopSession(threadId); }), @@ -918,7 +903,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits a turn diff update when a Copilot file-change turn completes", () => + 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"); @@ -991,31 +976,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } as SessionEvent); - let turnDiffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } + yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(turnDiffEvent?.type, "turn.diff.updated"); - if (turnDiffEvent?.type === "turn.diff.updated") { - assert.equal(turnDiffEvent.threadId, threadId); - assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); - assert.equal( - String(turnDiffEvent.itemId), - `copilot-tool-completion-${String(turn.turnId)}`, - ); - assert.deepStrictEqual(turnDiffEvent.payload, { - unifiedDiff: "", - }); - } + assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); yield* adapter.stopSession(threadId); }), ); - it.effect("emits a turn diff update when a Copilot write permission is approved", () => + it.effect("does not emit an empty turn diff when a Copilot write permission is approved", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-write-permission-turn-diff"); @@ -1119,25 +1089,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } as SessionEvent); - let turnDiffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && turnDiffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - turnDiffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } + yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(turnDiffEvent?.type, "turn.diff.updated"); - if (turnDiffEvent?.type === "turn.diff.updated") { - assert.equal(turnDiffEvent.threadId, threadId); - assert.equal(String(turnDiffEvent.turnId), String(turn.turnId)); - assert.equal( - String(turnDiffEvent.itemId), - `copilot-tool-completion-${String(turn.turnId)}`, - ); - assert.deepStrictEqual(turnDiffEvent.payload, { - unifiedDiff: "", - }); - } + assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 8f478d86bb1..2e4e706f1e2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -173,7 +173,6 @@ interface CopilotSessionContext { readonly pendingTaskCompletionTextByTurnId: Map; readonly turnIdsWithAssistantText: Set; readonly turnIdsWithFileChangeEvidence: Set; - readonly turnDiffEmittedTurnIds: Set; readonly startedItemIds: Set; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; @@ -318,30 +317,6 @@ function toolOnlyCompletionText( return "Done. I completed the requested tool work."; } -function turnHasSuccessfulFileChange(context: CopilotSessionContext, turnId: TurnId): boolean { - if (context.turnIdsWithFileChangeEvidence.has(turnId)) { - return true; - } - - const turn = context.turns.find((entry) => entry.id === turnId); - return ( - turn?.items.some( - (item): item is CopilotToolExecutionItem => - isCopilotToolExecutionItem(item) && item.success && item.itemType === "file_change", - ) ?? false - ); -} - -function turnHasSuccessfulTaskComplete(context: CopilotSessionContext, turnId: TurnId): boolean { - const turn = context.turns.find((entry) => entry.id === turnId); - return ( - turn?.items.some( - (item): item is CopilotToolExecutionItem => - isCopilotToolExecutionItem(item) && item.success && isTaskCompleteTool(item.toolName), - ) ?? false - ); -} - function assistantItemIdsForContext(context: CopilotSessionContext): Map { const mutable = context as CopilotSessionContext & { assistantItemIdByTurnId?: Map; @@ -350,14 +325,6 @@ function assistantItemIdsForContext(context: CopilotSessionContext): Map { - const mutable = context as CopilotSessionContext & { - turnDiffEmittedTurnIds?: Set; - }; - mutable.turnDiffEmittedTurnIds ??= new Set(); - return mutable.turnDiffEmittedTurnIds; -} - function markTurnHasFileChangeEvidence(context: CopilotSessionContext, turnId: TurnId): void { context.turnIdsWithFileChangeEvidence.add(turnId); } @@ -1273,35 +1240,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - const emitTurnDiffUpdatedForSnapshotCandidate = async ( - context: CopilotSessionContext, - turnId: TurnId, - raw: SessionEvent, - ) => { - const turnDiffEmittedTurnIds = turnDiffEmittedTurnIdsForContext(context); - if ( - turnDiffEmittedTurnIds.has(turnId) || - (!turnHasSuccessfulFileChange(context, turnId) && - !turnHasSuccessfulTaskComplete(context, turnId)) - ) { - return; - } - - turnDiffEmittedTurnIds.add(turnId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId: assistantItemIdsForContext(context).get(turnId), - raw, - }), - type: "turn.diff.updated", - payload: { - unifiedDiff: "", - }, - }); - }; - const emitTextDelta = async (input: { readonly context: CopilotSessionContext; readonly turnId: TurnId; @@ -1888,7 +1826,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!event.data.aborted) { await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); - await emitTurnDiffUpdatedForSnapshotCandidate(context, turnId, event); } await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { raw: event, @@ -2180,7 +2117,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); - await emitTurnDiffUpdatedForSnapshotCandidate(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, stopReason: null, @@ -2649,7 +2585,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( pendingTaskCompletionTextByTurnId: new Map(), turnIdsWithAssistantText: new Set(), turnIdsWithFileChangeEvidence: new Set(), - turnDiffEmittedTurnIds: new Set(), startedItemIds: new Set(), activeTurnId: undefined, activeSdkTurnId: undefined, From b8393caf4e3705bf1d674c68457ce5f420b66d1a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:22:14 +0200 Subject: [PATCH 046/104] Normalize Copilot user input answers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 98 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 14 ++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index ca12a7fa179..78684d9e693 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -249,6 +249,104 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits canonical answer maps for completed Copilot user input", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-user-input-canonical-answers"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onUserInputRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "user-input-canonical-answer"; + const request = { + question: "How should Copilot continue?", + choices: ["Use default"], + allowFreeform: true, + }; + + const responsePromise = Promise.resolve( + config.onUserInputRequest(request, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + emit({ + id: "evt-copilot-user-input-requested", + timestamp, + parentId: null, + type: "user_input.requested", + data: { + requestId, + ...request, + }, + } as SessionEvent); + + let requested: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && requested === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + requested = runtimeEvents.find( + (event) => event.type === "user-input.requested" && String(event.requestId) === requestId, + ); + } + assert.equal(requested?.type, "user-input.requested"); + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { + answer: "Use a custom answer", + }); + const response = yield* Effect.promise(() => responsePromise); + assert.deepStrictEqual(response, { + answer: "Use a custom answer", + wasFreeform: true, + }); + + emit({ + id: "evt-copilot-user-input-completed", + timestamp, + parentId: null, + type: "user_input.completed", + data: { + requestId, + answer: response.answer, + wasFreeform: response.wasFreeform, + }, + } as SessionEvent); + + let resolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && resolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + resolved = runtimeEvents.find( + (event) => event.type === "user-input.resolved" && String(event.requestId) === requestId, + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(resolved?.type, "user-input.resolved"); + if (resolved?.type === "user-input.resolved") { + assert.deepStrictEqual(resolved.payload.answers, { + answer: "Use a custom answer", + }); + assert.equal("wasFreeform" in resolved.payload.answers, false); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("passes selected Copilot context tier when creating a session", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 2e4e706f1e2..18b67f6f88e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -56,6 +56,7 @@ type CopilotUserInputResponse = Awaited< >; type SessionPermissionRequestedEvent = Extract; type SessionUserInputRequestedEvent = Extract; +type SessionUserInputCompletedEvent = Extract; type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; type SessionApprovalDecision = Extract; type SessionApproval = NonNullable; @@ -903,6 +904,14 @@ function answerFromUserInput( }; } +function answersFromCompletedUserInput( + data: SessionUserInputCompletedEvent["data"], +): ProviderUserInputAnswers { + return { + answer: data.answer ?? "", + }; +} + function settlePendingPermissionHandlers( context: CopilotSessionContext, ): Effect.Effect { @@ -2399,10 +2408,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "user-input.resolved", payload: { - answers: { - answer: event.data.answer ?? "", - wasFreeform: event.data.wasFreeform ?? true, - }, + answers: answersFromCompletedUserInput(event.data), }, }); return; From e0aa623b4337ab91d612696db2c5f239032aef8e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:23:21 +0200 Subject: [PATCH 047/104] Stream Copilot fallback assistant text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 33 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 18 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 78684d9e693..31cddd9cf71 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -674,6 +674,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + const fallbackDelta = runtimeEvents.find( + (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + assert.equal(fallbackDelta?.type, "content.delta"); + if (fallbackDelta?.type === "content.delta") { + assert.equal(String(fallbackDelta.itemId), `copilot-task-completion-${String(turn.turnId)}`); + assert.deepStrictEqual(fallbackDelta.payload, { + streamKind: "assistant_text", + delta: resultText, + }); + } assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); yield* adapter.stopSession(threadId); @@ -698,6 +709,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { attachments: [], }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); @@ -780,6 +798,21 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { content: "Done. I completed the requested file changes.", }); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const fallbackDelta = runtimeEvents.find( + (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + assert.equal(fallbackDelta?.type, "content.delta"); + if (fallbackDelta?.type === "content.delta") { + assert.equal(String(fallbackDelta.itemId), `copilot-tool-completion-${String(turn.turnId)}`); + assert.deepStrictEqual(fallbackDelta.payload, { + streamKind: "assistant_text", + delta: "Done. I completed the requested file changes.", + }); + } + yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 18b67f6f88e..104308ea84b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1312,6 +1312,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, @@ -1346,6 +1355,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-tool-completion-${String(turnId)}`; + await emitTextDelta({ + context, + turnId, + itemId, + itemType: "assistant_message", + streamKind: "assistant_text", + nextText: content, + raw, + }); await emitAsync({ ...createBaseEvent({ threadId: context.threadId, From c6644543c1ad039710682fea1b6ae2d176ce6ab3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:24:55 +0200 Subject: [PATCH 048/104] Add Copilot provider refs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 14 ++++++ .../src/provider/Layers/CopilotAdapter.ts | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 31cddd9cf71..1fcb79cdfaa 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -304,6 +304,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); } assert.equal(requested?.type, "user-input.requested"); + if (requested?.type === "user-input.requested") { + assert.equal(requested.providerRefs?.providerRequestId, requestId); + } yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { answer: "Use a custom answer", @@ -337,6 +340,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { assert.equal(resolved?.type, "user-input.resolved"); if (resolved?.type === "user-input.resolved") { + assert.equal(resolved.providerRefs?.providerRequestId, requestId); assert.deepStrictEqual(resolved.payload.answers, { answer: "Use a custom answer", }); @@ -685,6 +689,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { delta: resultText, }); } + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "collab_agent_tool_call" && + String(event.itemId) === "copilot-tool-tool-task-complete", + ); + assert.equal(completedTool?.type, "item.completed"); + if (completedTool?.type === "item.completed") { + assert.equal(completedTool.providerRefs?.providerItemId, "tool-task-complete"); + } assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 104308ea84b..b7a4e752e78 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -16,6 +16,7 @@ import { type ProviderApprovalDecision, ProviderDriverKind, ProviderInstanceId, + ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, @@ -215,6 +216,50 @@ function toCopilotResumeCursor(sessionId: string): { schemaVersion: 1; 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; @@ -223,6 +268,7 @@ function createBaseEvent(input: { readonly createdAt?: string | undefined; readonly raw?: SessionEvent | undefined; }) { + const providerRefs = providerRefsFromSdkEvent(input.raw, input.requestId); return { eventId: EventId.make(randomUUID()), provider: PROVIDER, @@ -231,6 +277,7 @@ function createBaseEvent(input: { ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(providerRefs ? { providerRefs } : {}), ...(input.raw ? { raw: { From f1c0122b6b8d437bb73829e81a6d02e0f8d95a76 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:26:57 +0200 Subject: [PATCH 049/104] Share provider tool classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/provider/Layers/CopilotAdapter.ts | 97 +----------- packages/shared/package.json | 4 + .../src/providerToolClassification.test.ts | 39 +++++ .../shared/src/providerToolClassification.ts | 138 ++++++++++++++++++ 4 files changed, 187 insertions(+), 91 deletions(-) create mode 100644 packages/shared/src/providerToolClassification.test.ts create mode 100644 packages/shared/src/providerToolClassification.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b7a4e752e78..b006c954261 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -30,6 +30,7 @@ import { type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { classifyProviderToolItemType } from "@t3tools/shared/providerToolClassification"; import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -665,102 +666,16 @@ function deltaFromBufferedText(previous: string | undefined, next: string): stri return next.slice(commonPrefixLength(previous ?? "", next)); } -function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean { - const args = stringRecord(arguments_); - if (!args) { - 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) => { - const value = args[key]; - return typeof value === "string" && trimOrUndefined(value) !== undefined; - }); - const hasEditPayload = editPayloadKeys.some((key) => args[key] !== undefined); - return hasFilePath && hasEditPayload; -} - -function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { - const normalized = toolName.toLowerCase(); - if ( - normalized.includes("write") || - normalized.includes("edit") || - normalized.includes("patch") || - normalized.includes("replace") - ) { - return true; - } - - if ( - normalized.includes("create") || - normalized.includes("delete") || - normalized.includes("remove") || - normalized.includes("modify") || - normalized.includes("update") || - normalized.includes("insert") - ) { - return normalized.includes("file") || toolArgumentsLookLikeFileChange(arguments_); - } - - return false; -} - function toolItemType( toolName: string, mcpServerName?: string, arguments_?: unknown, ): ToolMeta["itemType"] { - const normalized = toolName.toLowerCase(); - if (mcpServerName) { - return "mcp_tool_call"; - } - if ( - normalized.includes("bash") || - normalized.includes("shell") || - normalized.includes("exec") || - normalized.includes("command") - ) { - return "command_execution"; - } - if (toolNameImpliesFileChange(toolName, arguments_)) { - return "file_change"; - } - if (normalized.includes("search") || normalized.includes("fetch") || normalized.includes("web")) { - return "web_search"; - } - if (normalized.includes("image") || normalized.includes("screenshot")) { - return "image_view"; - } - if ( - normalized.includes("subagent") || - normalized.includes("agent") || - normalized.includes("delegate") || - normalized.includes("task") - ) { - return "collab_agent_tool_call"; - } - return "dynamic_tool_call"; + return classifyProviderToolItemType({ + toolName, + ...(mcpServerName ? { mcpServerName } : {}), + ...(arguments_ !== undefined ? { arguments: arguments_ } : {}), + }); } function isTaskCompleteTool(toolName: string | undefined): boolean { diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..28de7729f57 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -67,6 +67,10 @@ "types": "./src/toolActivity.ts", "import": "./src/toolActivity.ts" }, + "./providerToolClassification": { + "types": "./src/providerToolClassification.ts", + "import": "./src/providerToolClassification.ts" + }, "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts new file mode 100644 index 00000000000..b2cd407601e --- /dev/null +++ b/packages/shared/src/providerToolClassification.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; + +import { describe, it } from "@effect/vitest"; + +import { + classifyProviderToolItemType, + classifyProviderToolRequestType, +} from "./providerToolClassification.ts"; + +describe("providerToolClassification", () => { + it("classifies common provider tools consistently", () => { + assert.equal(classifyProviderToolItemType({ toolName: "bash" }), "command_execution"); + assert.equal(classifyProviderToolItemType({ toolName: "Task_complete" }), "collab_agent_tool_call"); + assert.equal( + classifyProviderToolItemType({ + toolName: "update", + arguments: { + path: "README.md", + content: "new content", + }, + }), + "file_change", + ); + assert.equal(classifyProviderToolItemType({ toolName: "Read" }), "dynamic_tool_call"); + assert.equal(classifyProviderToolItemType({ toolName: "web_fetch" }), "web_search"); + assert.equal(classifyProviderToolItemType({ toolName: "screenshot" }), "image_view"); + assert.equal( + classifyProviderToolItemType({ toolName: "call_tool", mcpServerName: "github" }), + "mcp_tool_call", + ); + }); + + it("classifies approval requests from the same rules", () => { + assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval"); + assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval"); + assert.equal(classifyProviderToolRequestType("Task"), "dynamic_tool_call"); + }); +}); diff --git a/packages/shared/src/providerToolClassification.ts b/packages/shared/src/providerToolClassification.ts new file mode 100644 index 00000000000..d4145754892 --- /dev/null +++ b/packages/shared/src/providerToolClassification.ts @@ -0,0 +1,138 @@ +import type { CanonicalRequestType, ToolLifecycleItemType } from "@t3tools/contracts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean { + if (!isRecord(arguments_)) { + return false; + } + + const filePathKeys = [ + "path", + "filePath", + "file_path", + "file", + "fileName", + "filename", + "targetFile", + "target_file", + ]; + const editPayloadKeys = [ + "content", + "newContent", + "new_content", + "oldString", + "old_string", + "newString", + "new_string", + "diff", + "patch", + "edits", + ]; + + const hasFilePath = filePathKeys.some((key) => trimmedString(arguments_[key]) !== undefined); + const hasEditPayload = editPayloadKeys.some((key) => arguments_[key] !== undefined); + return hasFilePath && hasEditPayload; +} + +function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("replace") + ) { + return true; + } + + if ( + normalized.includes("create") || + normalized.includes("delete") || + normalized.includes("remove") || + normalized.includes("modify") || + normalized.includes("update") || + normalized.includes("insert") + ) { + return normalized.includes("file") || toolArgumentsLookLikeFileChange(arguments_); + } + + return toolArgumentsLookLikeFileChange(arguments_); +} + +export function isReadOnlyProviderToolName(toolName: string): boolean { + const normalized = toolName.toLowerCase(); + return ( + normalized === "read" || + normalized.includes("read file") || + normalized.includes("view") || + normalized.includes("grep") || + normalized.includes("glob") || + normalized.includes("search") + ); +} + +export function classifyProviderToolItemType(input: { + readonly toolName: string; + readonly mcpServerName?: string | undefined; + readonly arguments?: unknown; +}): ToolLifecycleItemType { + const normalized = input.toolName.toLowerCase(); + if (input.mcpServerName || normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if ( + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") || + normalized.includes("agent") || + normalized.includes("delegate") || + normalized.includes("task") + ) { + return "collab_agent_tool_call"; + } + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("exec") || + normalized.includes("command") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if (toolNameImpliesFileChange(input.toolName, input.arguments)) { + return "file_change"; + } + if ( + normalized.includes("websearch") || + normalized.includes("web search") || + normalized.includes("web_search") || + normalized.includes("web") || + normalized.includes("fetch") + ) { + return "web_search"; + } + if (normalized.includes("image") || normalized.includes("screenshot")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +export function classifyProviderToolRequestType(toolName: string): CanonicalRequestType { + if (isReadOnlyProviderToolName(toolName)) { + return "file_read_approval"; + } + const itemType = classifyProviderToolItemType({ toolName }); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : "dynamic_tool_call"; +} From 3d84c8d224971bca9fd48596de69b6c4e2ea8fd7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:28:15 +0200 Subject: [PATCH 050/104] Normalize provider plan updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 71 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 6 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 1fcb79cdfaa..86e894b3140 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1390,6 +1390,77 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("ignores empty Copilot background task plan updates", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-empty-background-tasks-plan"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-empty-background-tasks", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-empty-background-tasks-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-empty-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores background task change events when Copilot cannot list tasks", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b006c954261..d88b8f7ab00 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1535,6 +1535,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!taskList) { return; } + const plan = planStepsFromCopilotTasks(taskList.tasks); + if (plan.length === 0) { + return; + } yield* emit({ ...createBaseEvent({ threadId: context.threadId, @@ -1544,7 +1548,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( type: "turn.plan.updated", payload: { explanation: "Copilot Tasks", - plan: planStepsFromCopilotTasks(taskList.tasks), + plan, }, }); }); From bba948677bceb4630a4aad98e3080994779551f1 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:29:25 +0200 Subject: [PATCH 051/104] Project provider reasoning deltas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderRuntimeIngestion.test.ts | 65 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 39 +++++++++++ 2 files changed, 104 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..1bcf64d6f4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2665,6 +2665,71 @@ 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 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..69f420f5304 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -554,6 +554,45 @@ function runtimeEventToActivities( ]; } + case "content.delta": { + if ( + event.payload.streamKind !== "reasoning_text" && + event.payload.streamKind !== "reasoning_summary_text" + ) { + return []; + } + if (event.payload.delta.trim().length === 0) { + 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 []; From a6d5a5366e54508c33efa532a7ac07398a3db97b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:30:13 +0200 Subject: [PATCH 052/104] Project provider tool output deltas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderRuntimeIngestion.test.ts | 62 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 34 +++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1bcf64d6f4b..9f84c4ec241 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2730,6 +2730,68 @@ describe("ProviderRuntimeIngestion", () => { }); }); + 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 69f420f5304..eb177065bb7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -555,15 +555,43 @@ 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 []; } - if (event.payload.delta.trim().length === 0) { - return []; - } return [ { id: event.eventId, From 63e6525dcac3bdc81ce71af6daf25d86143aa062 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:31:05 +0200 Subject: [PATCH 053/104] Guard duplicate Copilot completions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 121 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 7 +- .../src/providerToolClassification.test.ts | 5 +- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 86e894b3140..f5452070141 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -679,11 +679,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const fallbackDelta = runtimeEvents.find( - (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", ); assert.equal(fallbackDelta?.type, "content.delta"); if (fallbackDelta?.type === "content.delta") { - assert.equal(String(fallbackDelta.itemId), `copilot-task-completion-${String(turn.turnId)}`); + assert.equal( + String(fallbackDelta.itemId), + `copilot-task-completion-${String(turn.turnId)}`, + ); assert.deepStrictEqual(fallbackDelta.payload, { streamKind: "assistant_text", delta: resultText, @@ -699,7 +703,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { if (completedTool?.type === "item.completed") { assert.equal(completedTool.providerRefs?.providerItemId, "tool-task-complete"); } - assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); yield* adapter.stopSession(threadId); }), @@ -820,7 +827,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); assert.equal(fallbackDelta?.type, "content.delta"); if (fallbackDelta?.type === "content.delta") { - assert.equal(String(fallbackDelta.itemId), `copilot-tool-completion-${String(turn.turnId)}`); + assert.equal( + String(fallbackDelta.itemId), + `copilot-tool-completion-${String(turn.turnId)}`, + ); assert.deepStrictEqual(fallbackDelta.payload, { streamKind: "assistant_text", delta: "Done. I completed the requested file changes.", @@ -1060,7 +1070,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ + yield* adapter.sendTurn({ threadId, input: "edit the docs", attachments: [], @@ -1124,7 +1134,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); yield* adapter.stopSession(threadId); }), @@ -1237,7 +1250,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(runtimeEvents.some((event) => event.type === "turn.diff.updated"), false); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); yield* adapter.stopSession(threadId); }), @@ -1726,6 +1742,97 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits one canonical turn completion for duplicate Copilot lifecycle events", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-duplicate-lifecycle-completion"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "complete once", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-duplicate-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-duplicate-turn-end-again", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-duplicate-completion", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + runtimeEvents.filter((event) => event.type === "turn.completed").length === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + assert.equal(completions.length, 1); + assert.equal(completions[0]?.type, "turn.completed"); + if (completions[0]?.type === "turn.completed") { + assert.equal(completions[0].payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("drains queued SDK events before disconnecting on stop", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index d88b8f7ab00..41c3c05668d 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -217,7 +217,10 @@ function toCopilotResumeCursor(sessionId: string): { schemaVersion: 1; sessionId }; } -function readTrimmedStringProperty(record: Record, key: string): string | undefined { +function readTrimmedStringProperty( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" ? trimOrUndefined(value) : undefined; } @@ -1177,6 +1180,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( readonly raw?: SessionEvent | undefined; }, ) => { + // Copilot can report both assistant.turn_end and session.idle for the same + // turn; keep the public runtime lifecycle canonical and idempotent. if (context.completedTurnIds.has(turnId)) { context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts index b2cd407601e..b9bc36c8593 100644 --- a/packages/shared/src/providerToolClassification.test.ts +++ b/packages/shared/src/providerToolClassification.test.ts @@ -10,7 +10,10 @@ import { describe("providerToolClassification", () => { it("classifies common provider tools consistently", () => { assert.equal(classifyProviderToolItemType({ toolName: "bash" }), "command_execution"); - assert.equal(classifyProviderToolItemType({ toolName: "Task_complete" }), "collab_agent_tool_call"); + assert.equal( + classifyProviderToolItemType({ toolName: "Task_complete" }), + "collab_agent_tool_call", + ); assert.equal( classifyProviderToolItemType({ toolName: "update", From 3c3accfdbfbe0d466b7acc4130d5b82db7ee9103 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 13 Jun 2026 22:06:10 +0200 Subject: [PATCH 054/104] Fix queued Copilot turn idle handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 160 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 37 +++- 2 files changed, 189 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index f5452070141..ff53e4d2b35 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1833,6 +1833,166 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("does not complete a queued turn from the previous Copilot idle event", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-queued-turn-idle-correlation"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + assert.equal( + runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ), + true, + ); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-queued-first-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* waitForSdkEventQueue(); + + const completionsAfterFirstIdle = runtimeEvents.filter( + (event) => event.type === "turn.completed", + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(firstTurn.turnId), + ).length, + 1, + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(secondTurn.turnId), + ).length, + 0, + ); + const latestEventAfterFirstIdle = runtimeEvents.at(-1); + assert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); + assert.equal(latestEventAfterFirstIdle.payload.state, "running"); + + emit({ + id: "evt-copilot-queued-second-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-queued-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-queued-second-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-queued-second", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ).length === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const secondCompletions = runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + assert.equal(secondCompletions.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("drains queued SDK events before disconnecting on stop", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 41c3c05668d..479eca60bbd 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -657,6 +657,12 @@ function updateProviderSession( }; } +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]) { @@ -1194,7 +1200,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.activeTurnId = undefined; } updateProviderSession(context, { - status: status === "failed" ? "error" : context.stopped ? "closed" : "ready", + status: + status === "failed" + ? "error" + : context.stopped + ? "closed" + : readyStatusAfterTurnCompletion(context), ...(status === "failed" && input?.errorMessage ? { lastError: input.errorMessage } : {}), activeTurnId: undefined, }); @@ -1830,8 +1841,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( stopReason: event.data.aborted ? "aborted" : null, }); } + const idleStatus = context.stopped ? "closed" : readyStatusAfterTurnCompletion(context); + const idleState = context.stopped ? "stopped" : readyStatusAfterTurnCompletion(context); updateProviderSession(context, { - status: context.stopped ? "closed" : "ready", + status: idleStatus, activeTurnId: undefined, }); await emitAsync({ @@ -1841,7 +1854,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "session.state.changed", payload: { - state: context.stopped ? "stopped" : "ready", + state: idleState, reason: event.data.aborted ? "Copilot turn aborted." : "Copilot idle.", }, }); @@ -2671,10 +2684,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ensureTurnSnapshot(context, turnId); context.queuedTurnIds.push(turnId); - context.activeTurnId = turnId; + const shouldPromoteQueuedTurn = + context.activeTurnId === undefined && context.activeSdkTurnId === undefined; + if (shouldPromoteQueuedTurn) { + context.activeTurnId = turnId; + } updateProviderSession(context, { status: "running", - activeTurnId: turnId, + ...(shouldPromoteQueuedTurn ? { activeTurnId: turnId } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), }); @@ -2704,10 +2721,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (queueIndex >= 0) { context.queuedTurnIds.splice(queueIndex, 1); } - context.activeTurnId = undefined; + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } updateProviderSession(context, { - status: "ready", - activeTurnId: undefined, + status: context.activeTurnId ? "running" : readyStatusAfterTurnCompletion(context), + ...(context.activeTurnId + ? { activeTurnId: context.activeTurnId } + : { activeTurnId: undefined }), }); yield* emit({ ...createBaseEvent({ From 5b6ee0e131c922142781f26b111af965dc225118 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 08:48:21 +0200 Subject: [PATCH 055/104] Suppress Copilot generic completion fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 159 ++++++++++++++---- .../src/provider/Layers/CopilotAdapter.ts | 126 -------------- 2 files changed, 125 insertions(+), 160 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index ff53e4d2b35..8544ab53c39 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -712,10 +712,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("renders a fallback assistant message when a tool-only turn completes", () => + it.effect("does not render the file-change completion fallback as assistant text", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-tool-only-assistant-fallback"); + const threadId = asThreadId("copilot-file-change-fallback-filter"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -743,12 +743,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const timestamp = yield* nowIso; emit({ - id: "evt-copilot-tool-only-turn-start", + id: "evt-copilot-file-change-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-tool-only", + turnId: "sdk-turn-file-change", }, } as SessionEvent); emit({ @@ -776,66 +776,157 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } as SessionEvent); emit({ - id: "evt-copilot-tool-only-turn-end", + id: "evt-copilot-file-change-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-tool-only", + turnId: "sdk-turn-file-change", }, } 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", - ), + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); attempt += 1 ) { yield* waitForSdkEventQueue(); - thread = yield* adapter.readThread(threadId); } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + const thread = yield* adapter.readThread(threadId); const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); assert.ok(turnSnapshot); - const assistantItem = turnSnapshot.items.find( + const assistantItems = turnSnapshot.items.filter( (item) => typeof item === "object" && item !== null && "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItem, { - type: "assistant_message", - messageId: `copilot-tool-completion-${String(turn.turnId)}`, - content: "Done. I completed the requested file changes.", + assert.deepStrictEqual(assistantItems, []); + assert.equal( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not render the generic tool completion fallback as assistant text", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-generic-tool-fallback-filter"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", }); - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + const turn = yield* adapter.sendTurn({ + threadId, + input: "inspect the README", + attachments: [], + }); - const fallbackDelta = runtimeEvents.find( - (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, ); - assert.equal(fallbackDelta?.type, "content.delta"); - if (fallbackDelta?.type === "content.delta") { - assert.equal( - String(fallbackDelta.itemId), - `copilot-tool-completion-${String(turn.turnId)}`, + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-generic-tool-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-generic-tool", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-read-file", + toolName: "Read", + arguments: { + path: "README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-read-file", + success: true, + result: { + content: "# Project", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-generic-tool-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-generic-tool", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - assert.deepStrictEqual(fallbackDelta.payload, { - streamKind: "assistant_text", - delta: "Done. I completed the requested file changes.", - }); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const thread = yield* adapter.readThread(threadId); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItems = turnSnapshot.items.filter( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItems, []); + assert.equal( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + false, + ); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 479eca60bbd..7c5104a7fd8 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -175,7 +175,6 @@ interface CopilotSessionContext { readonly assistantItemIdByTurnId: Map; readonly pendingTaskCompletionTextByTurnId: Map; readonly turnIdsWithAssistantText: Set; - readonly turnIdsWithFileChangeEvidence: Set; readonly startedItemIds: Set; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; @@ -315,60 +314,6 @@ function appendTurnItem( ensureTurnSnapshot(context, turnId).items.push(item); } -function isCopilotToolExecutionItem(item: unknown): item is CopilotToolExecutionItem { - return ( - Predicate.hasProperty(item, "type") && - item.type === "tool_execution" && - Predicate.hasProperty(item, "toolCallId") && - Predicate.isString(item.toolCallId) && - Predicate.hasProperty(item, "success") && - typeof item.success === "boolean" - ); -} - -function isTaskCompletedDetail(detail: string | undefined): boolean { - return detail?.trim().startsWith("✓ Task completed:") ?? false; -} - -function toolOnlyCompletionText( - context: CopilotSessionContext, - turnId: TurnId, -): string | undefined { - if (context.turnIdsWithAssistantText.has(turnId)) { - return undefined; - } - - const turn = context.turns.find((entry) => entry.id === turnId); - const completedTools = - turn?.items.filter( - (item): item is CopilotToolExecutionItem => isCopilotToolExecutionItem(item) && item.success, - ) ?? []; - if (completedTools.length === 0) { - if (context.turnIdsWithFileChangeEvidence.has(turnId)) { - return "Done. I completed the requested file changes."; - } - return undefined; - } - - if (completedTools.some((item) => isTaskCompletedDetail(item.detail))) { - return undefined; - } - - const itemTypes = new Set(completedTools.map((item) => item.itemType).filter(Boolean)); - if (itemTypes.has("file_change")) { - return "Done. I completed the requested file changes."; - } - if ( - itemTypes.size > 0 && - [...itemTypes].every( - (itemType) => itemType === "command_execution" || itemType === "collab_agent_tool_call", - ) - ) { - return undefined; - } - return "Done. I completed the requested tool work."; -} - function assistantItemIdsForContext(context: CopilotSessionContext): Map { const mutable = context as CopilotSessionContext & { assistantItemIdByTurnId?: Map; @@ -377,10 +322,6 @@ function assistantItemIdsForContext(context: CopilotSessionContext): Map { - const content = toolOnlyCompletionText(context, turnId); - if (!content) { - return; - } - - const itemId = `copilot-tool-completion-${String(turnId)}`; - await emitTextDelta({ - context, - turnId, - itemId, - itemType: "assistant_message", - streamKind: "assistant_text", - nextText: content, - raw, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw, - }), - type: "item.completed", - payload: { - itemType: "assistant_message", - status: "completed", - detail: content, - }, - }); - appendTurnItem(context, turnId, { - type: "assistant_message", - messageId: itemId, - content, - }); - assistantItemIdsForContext(context).set(turnId, itemId); - context.turnIdsWithAssistantText.add(turnId); - }; - const emitPermissionRequestOpened = ( context: CopilotSessionContext, pending: PendingPermissionBinding, @@ -1834,7 +1728,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = context.activeTurnId; if (!event.data.aborted) { await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); - await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); } await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { raw: event, @@ -2127,7 +2020,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); - await emitToolOnlyCompletionAsAssistantMessage(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, stopReason: null, @@ -2339,9 +2231,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...(detail ? { detail } : {}), }; appendTurnItem(context, turnId, toolItem); - if (event.data.success && toolMeta?.itemType === "file_change") { - markTurnHasFileChangeEvidence(context, turnId); - } if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { context.pendingTaskCompletionTextByTurnId.set(turnId, detail); } @@ -2364,13 +2253,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } context.pendingPermissionBindings.delete(event.data.requestId); - if ( - binding.requestType === "file_change_approval" && - binding.turnId && - permissionResultApproves(event.data.result) - ) { - markTurnHasFileChangeEvidence(context, binding.turnId); - } await runWithContext( emitPermissionRequestResolved( context, @@ -2592,7 +2474,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( assistantItemIdByTurnId: new Map(), pendingTaskCompletionTextByTurnId: new Map(), turnIdsWithAssistantText: new Set(), - turnIdsWithFileChangeEvidence: new Set(), startedItemIds: new Set(), activeTurnId: undefined, activeSdkTurnId: undefined, @@ -2815,13 +2696,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : DENIED_PERMISSION_RESULT; yield* emitPermissionRequestResolved(context, binding, decision, result); context.pendingPermissionBindings.delete(requestId); - if ( - binding.requestType === "file_change_approval" && - binding.turnId && - permissionResultApproves(result) - ) { - markTurnHasFileChangeEvidence(context, binding.turnId); - } yield* Deferred.succeed(binding.deferred, result); }, ); From c1363190b6a56d0aeaa3d6b9cfcbdd6c5e4d652b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:05:07 +0200 Subject: [PATCH 056/104] Trigger Copilot diff checkpoints for tool completions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProjectionPipeline.ts | 12 +- .../provider/Layers/CopilotAdapter.test.ts | 208 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 26 +++ .../src/state/threadReducer.test.ts | 50 +++++ .../client-runtime/src/state/threadReducer.ts | 6 + 5 files changed, 301 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..debc4b45c74 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -755,9 +755,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + const session = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const anotherTurnStillRunning = + Option.isSome(session) && + session.value.status === "running" && + session.value.activeTurnId !== null && + session.value.activeTurnId !== event.payload.turnId; yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: event.payload.turnId, + latestTurnId: anotherTurnStillRunning + ? existingRow.value.latestTurnId + : event.payload.turnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 8544ab53c39..1d2ee54f7f9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1234,6 +1234,214 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("emits a file-change diff turn when a Copilot Apply_patch tool completes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-apply-patch-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "edit the docs", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; + + emit({ + id: "evt-copilot-apply-patch-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-apply-patch", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-apply-patch", + toolName: "Apply_patch", + arguments: { + patch, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-apply-patch", + success: true, + result: { + content: patch, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-apply-patch-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-apply-patch", + }, + } as SessionEvent); + + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal( + String(diffEvent.turnId), + `${String(turn.turnId)}:file-change:tool-apply-patch`, + ); + assert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: patch, + }); + } + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "file_change" && + String(event.itemId) === "copilot-tool-tool-apply-patch", + ); + assert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits a file-change diff turn when a Copilot command tool completes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + 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: "done", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal(String(diffEvent.turnId), `${String(turn.turnId)}:file-change:tool-command`); + assert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: "done", + }); + } + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + String(event.itemId) === "copilot-tool-tool-command", + ); + assert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("does not emit an empty turn diff when a Copilot write permission is approved", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 7c5104a7fd8..ec1fa6c5610 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -628,6 +628,19 @@ 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 shouldEmitFileChangeDiffForCompletedTool(toolMeta: ToolMeta | undefined): boolean { + return toolMeta?.itemType === "command_execution" || isApplyPatchTool(toolMeta?.toolName); +} + +function fileChangeTurnIdForToolCall(parentTurnId: TurnId, toolCallId: string): TurnId { + const normalizedToolCallId = toolCallId.replace(/[^A-Za-z0-9._:-]+/g, "_") || "tool"; + return TurnId.make(`${String(parentTurnId)}:file-change:${normalizedToolCallId}`); +} + function copilotBackgroundTasksList( session: CopilotSession, ): (() => Promise) | undefined { @@ -2222,6 +2235,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), }, }); + if (event.data.success && shouldEmitFileChangeDiffForCompletedTool(toolMeta)) { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: fileChangeTurnIdForToolCall(turnId, event.data.toolCallId), + raw: event, + }), + type: "turn.diff.updated", + payload: { + unifiedDiff: detail ?? "", + }, + }); + } const toolItem: CopilotToolExecutionItem = { type: "tool_execution", toolCallId: event.data.toolCallId, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..7dd96d48e3c 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -596,6 +596,56 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.latestTurn?.state).toBe("completed"); } }); + + it("does not replace latestTurn while a different turn is running", () => { + const runningTurnId = TurnId.make("turn-2"); + const diffTurnId = TurnId.make("turn-1"); + const threadWithRunningSession: OrchestrationThread = { + ...baseThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "copilot", + runtimeMode: "full-access", + activeTurnId: runningTurnId, + lastError: null, + updatedAt: "2026-04-01T11:59:00.000Z", + }, + latestTurn: { + turnId: runningTurnId, + state: "running", + requestedAt: "2026-04-01T11:59:00.000Z", + startedAt: "2026-04-01T11:59:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }; + + const result = applyThreadDetailEvent(threadWithRunningSession, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T12:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.turn-diff-completed", + payload: { + threadId: ThreadId.make("thread-1"), + turnId: diffTurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-3"), + completedAt: "2026-04-01T12:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.checkpoints).toHaveLength(1); + expect(result.thread.latestTurn).toEqual(threadWithRunningSession.latestTurn); + } + }); }); describe("thread.reverted", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..ee6d3ffd278 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -385,9 +385,15 @@ 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 anotherTurnStillRunning = + thread.session?.status === "running" && + thread.session.activeTurnId !== null && + thread.session.activeTurnId !== event.payload.turnId; const latestTurn = !diffTurnStillRunning && + !anotherTurnStillRunning && (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, From ecc7e911f26b9a1830b1ab9d43832e9f2856c82f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:52:50 +0200 Subject: [PATCH 057/104] Make checkpoint revert rollback-safe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/CheckpointReactor.test.ts | 103 ++++++++++++++++-- .../orchestration/Layers/CheckpointReactor.ts | 33 ++++-- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..a2ab1346578 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 = () => @@ -279,6 +281,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 +291,7 @@ describe("CheckpointReactor", () => { options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, options?.providerName ?? ProviderDriverKind.make("codex"), + options?.rollbackConversation, ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -1038,6 +1042,91 @@ describe("CheckpointReactor", () => { }); }); + it("does not restore 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 Effect.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 Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-copilot-request"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), + ).toBe(true); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; @@ -1060,7 +1149,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 +1163,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 +1178,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 +1187,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 +1214,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..23a91eaaf8f 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 rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); + if (rolledBackTurns > 0) { + yield* providerService.rollbackConversation({ + threadId: sessionRuntime.value.threadId, + numTurns: rolledBackTurns, + }); + } + const restored = yield* checkpointStore.restoreCheckpoint({ cwd: sessionRuntime.value.cwd, checkpointRef: targetCheckpointRef, @@ -694,14 +719,6 @@ const make = Effect.gen(function* () { // 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) { From f0d32d25752c0d89d3d8681ca77ec826d3f95054 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:55:08 +0200 Subject: [PATCH 058/104] Restore Copilot first-turn branch naming Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderCommandReactor.test.ts | 63 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 32 ---------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5ebc2e3a59c..63806798d93 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -885,6 +885,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"; @@ -2199,7 +2258,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2232,7 +2291,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"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index be0d5358ff3..0373a8694ae 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -88,7 +88,6 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; -const COPILOT_PROVIDER = ProviderDriverKind.make("copilot"); export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -220,26 +219,6 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); - const shouldSkipCopilotFirstTurnBranchGeneration = Effect.fnUntraced(function* (input: { - readonly threadModelSelection: ModelSelection; - readonly textGenerationModelSelection: ModelSelection; - }) { - const [threadProvider, textGenerationProvider] = yield* Effect.all( - [ - providerService.getInstanceInfo(input.threadModelSelection.instanceId).pipe(Effect.option), - providerService - .getInstanceInfo(input.textGenerationModelSelection.instanceId) - .pipe(Effect.option), - ], - { concurrency: "unbounded" }, - ); - - return ( - Option.getOrUndefined(threadProvider)?.driverKind === COPILOT_PROVIDER && - Option.getOrUndefined(textGenerationProvider)?.driverKind === COPILOT_PROVIDER - ); - }); - const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; readonly kind: @@ -674,7 +653,6 @@ const make = Effect.gen(function* () { "maybeGenerateAndRenameWorktreeBranchForFirstTurn", )(function* (input: { readonly threadId: ThreadId; - readonly threadModelSelection: ModelSelection; readonly branch: string | null; readonly worktreePath: string | null; readonly messageText: string; @@ -693,15 +671,6 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - if ( - yield* shouldSkipCopilotFirstTurnBranchGeneration({ - threadModelSelection: input.threadModelSelection, - textGenerationModelSelection: modelSelection, - }) - ) { - return; - } - const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, @@ -836,7 +805,6 @@ const make = Effect.gen(function* () { yield* firstTurnAuxiliaryWorker.enqueue( maybeGenerateAndRenameWorktreeBranchForFirstTurn({ threadId: event.payload.threadId, - threadModelSelection: thread.modelSelection, branch: thread.branch, worktreePath: thread.worktreePath, ...generationInput, From 00d27198ecf412dba8317c1e0468f9431dd0ed3b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 10:15:30 +0200 Subject: [PATCH 059/104] Filter empty Copilot diff turns --- .../provider/Layers/CopilotAdapter.test.ts | 278 ++++++++++++------ .../src/provider/Layers/CopilotAdapter.ts | 36 ++- 2 files changed, 223 insertions(+), 91 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 1d2ee54f7f9..dc63d994d60 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1340,106 +1340,210 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits a file-change diff turn when a Copilot command tool completes", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-turn-diff"); + it.effect( + "does not emit a file-change diff turn when a Copilot command tool returns plain output", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-turn-diff"); - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "run a command", - attachments: [], - }); + 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 runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); - const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; - emit({ - id: "evt-copilot-command-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-command", - toolName: "bash", - arguments: { - command: "printf done > README.md", + 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-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-command", - success: true, - result: { - content: "done", + } 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-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); + } as SessionEvent); + emit({ + id: "evt-copilot-command-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command", + success: true, + result: { + content: "done", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command", + }, + } 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); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - assert.equal(String(diffEvent.turnId), `${String(turn.turnId)}:file-change:tool-command`); - assert.deepStrictEqual(diffEvent.payload, { - unifiedDiff: "done", + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + String(event.itemId) === "copilot-tool-tool-command", + ); + assert.equal(completedTool?.type, "item.completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "emits a file-change diff turn when a Copilot command tool returns a unified diff", + () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-unified-diff-turn"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", }); - } - const completedTool = runtimeEvents.find( - (event) => - event.type === "item.completed" && - event.payload.itemType === "command_execution" && - String(event.itemId) === "copilot-tool-tool-command", - ); - assert.equal(completedTool?.type, "item.completed"); + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + }); - yield* adapter.stopSession(threadId); - }), + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const diff = [ + "diff --git a/README.md b/README.md", + "index 1111111..2222222 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"); + + emit({ + id: "evt-copilot-command-diff-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command-diff", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command-diff", + toolName: "bash", + arguments: { + command: "git diff -- README.md", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-command-diff", + success: true, + result: { + content: diff, + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-diff-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-command-diff", + }, + } as SessionEvent); + + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal( + String(diffEvent.turnId), + `${String(turn.turnId)}:file-change:tool-command-diff`, + ); + assert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), ); it.effect("does not emit an empty turn diff when a Copilot write permission is approved", () => diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index ec1fa6c5610..c8e94558801 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -34,6 +34,7 @@ import { classifyProviderToolItemType } from "@t3tools/shared/providerToolClassi import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderAdapterProcessError, @@ -632,8 +633,34 @@ function isApplyPatchTool(toolName: string | undefined): boolean { return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "applypatch"; } -function shouldEmitFileChangeDiffForCompletedTool(toolMeta: ToolMeta | undefined): boolean { - return toolMeta?.itemType === "command_execution" || isApplyPatchTool(toolMeta?.toolName); +function hasApplyPatchEdit(detail: string): boolean { + const normalized = detail.replace(/\r\n/g, "\n").trim(); + if (!normalized.startsWith("*** Begin Patch")) { + return false; + } + return ( + normalized.includes("\n*** Update File: ") || + normalized.includes("\n*** Add File: ") || + normalized.includes("\n*** Delete File: ") || + normalized.includes("\n*** Move to: ") + ); +} + +function completedToolDiffText( + toolMeta: ToolMeta | undefined, + detail: string | undefined, +): string | undefined { + const normalized = trimOrUndefined(detail); + if (!normalized) { + return undefined; + } + if (isApplyPatchTool(toolMeta?.toolName)) { + return hasApplyPatchEdit(normalized) ? normalized : undefined; + } + if (toolMeta?.itemType !== "command_execution") { + return undefined; + } + return parseTurnDiffFilesFromUnifiedDiff(normalized).length > 0 ? normalized : undefined; } function fileChangeTurnIdForToolCall(parentTurnId: TurnId, toolCallId: string): TurnId { @@ -2235,7 +2262,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), }, }); - if (event.data.success && shouldEmitFileChangeDiffForCompletedTool(toolMeta)) { + const diffText = event.data.success ? completedToolDiffText(toolMeta, detail) : undefined; + if (diffText) { await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -2244,7 +2272,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "turn.diff.updated", payload: { - unifiedDiff: detail ?? "", + unifiedDiff: diffText, }, }); } From bbb42bdbd89377feb5bef329fa782b87770d08f4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 11:22:01 +0200 Subject: [PATCH 060/104] Ignore Copilot shell completion lines in diffs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 173 ++++++++++-------- .../src/provider/Layers/CopilotAdapter.ts | 24 ++- 2 files changed, 116 insertions(+), 81 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index dc63d994d60..39f3ee87bdc 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1341,99 +1341,112 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); it.effect( - "does not emit a file-change diff turn when a Copilot command tool returns plain output", + "does not emit a file-change diff turn when a Copilot command tool returns control output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-command-turn-diff"); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); - yield* adapter.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(); + yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + }); - const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); - 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", + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command", }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-command", - success: true, - result: { - content: "done", + } 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-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); + } 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); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal( - runtimeEvents.some((event) => event.type === "turn.diff.updated"), - false, - ); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); - const completedTool = runtimeEvents.find( - (event) => - event.type === "item.completed" && - event.payload.itemType === "command_execution" && - String(event.itemId) === "copilot-tool-tool-command", - ); - assert.equal(completedTool?.type, "item.completed"); + const parserErrorCalls = [ + ...consoleErrorSpy.mock.calls, + ...consoleLogSpy.mock.calls, + ].filter((args) => args.some((arg) => String(arg).includes("parseLineType"))); + assert.deepStrictEqual(parserErrorCalls, []); + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + String(event.itemId) === "copilot-tool-tool-command", + ); + assert.equal(completedTool?.type, "item.completed"); - yield* adapter.stopSession(threadId); + yield* adapter.stopSession(threadId); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + } }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index c8e94558801..72b514755d9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -646,6 +646,24 @@ function hasApplyPatchEdit(detail: string): boolean { ); } +const SHELL_COMPLETION_CONTROL_LINE_PATTERN = /^]+ completed with exit code \d+>$/; + +function stripShellCompletionControlLines(detail: string): string { + return detail + .replace(/\r\n/g, "\n") + .split("\n") + .filter((line) => !SHELL_COMPLETION_CONTROL_LINE_PATTERN.test(line.trim())) + .join("\n") + .trim(); +} + +function hasUnifiedDiffShape(detail: string): boolean { + return ( + detail.includes("diff --git ") || + /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+\n@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(detail) + ); +} + function completedToolDiffText( toolMeta: ToolMeta | undefined, detail: string | undefined, @@ -660,7 +678,11 @@ function completedToolDiffText( if (toolMeta?.itemType !== "command_execution") { return undefined; } - return parseTurnDiffFilesFromUnifiedDiff(normalized).length > 0 ? normalized : undefined; + const diffCandidate = stripShellCompletionControlLines(normalized); + if (!hasUnifiedDiffShape(diffCandidate)) { + return undefined; + } + return parseTurnDiffFilesFromUnifiedDiff(diffCandidate).length > 0 ? diffCandidate : undefined; } function fileChangeTurnIdForToolCall(parentTurnId: TurnId, toolCallId: string): TurnId { From f12853b744d7d92be9c375f3ad890cc90f00c54f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:11:35 +0200 Subject: [PATCH 061/104] Fix Copilot session error turn completion Ensure Copilot session.error events complete the active turn as failed before clearing active turn state, and cover the lifecycle with a regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 69 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 5 +- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 39f3ee87bdc..ae0bb61b6e2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -2158,6 +2158,75 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("completes the active turn as failed when Copilot reports a session error", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-session-error-completes-active-turn"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "trigger a provider session error", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-session-error-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-session-error", + }, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-session-error", + timestamp, + parentId: null, + type: "session.error", + data: { + message: "Copilot runtime crashed", + }, + } as SessionEvent); + + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + completed = runtimeEvents.find( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal(runtimeError?.type, "runtime.error"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Copilot runtime crashed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits one canonical turn completion for duplicate Copilot lifecycle events", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 72b514755d9..889053c80a0 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1748,13 +1748,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } 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 (context.activeTurnId) { - await emitTurnCompleted(context, context.activeTurnId, "failed", { + if (activeTurnId) { + await emitTurnCompleted(context, activeTurnId, "failed", { errorMessage: message, raw: event, }); From f5ebf1e5d28d075e875af09b9ae93cbb08a61c16 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:48:25 +0200 Subject: [PATCH 062/104] Fix Copilot adapter formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/provider/Layers/CopilotAdapter.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 889053c80a0..4007aa4c4ee 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2620,12 +2620,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); 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; + 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, { From d065a3167e7b470c1259cd14b30d0bfc85d16c87 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:55:25 +0200 Subject: [PATCH 063/104] Fix web search approval classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/shared/src/providerToolClassification.test.ts | 2 ++ packages/shared/src/providerToolClassification.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts index b9bc36c8593..fabc4bd3322 100644 --- a/packages/shared/src/providerToolClassification.test.ts +++ b/packages/shared/src/providerToolClassification.test.ts @@ -37,6 +37,8 @@ describe("providerToolClassification", () => { assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval"); assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval"); assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval"); + assert.equal(classifyProviderToolRequestType("websearch"), "dynamic_tool_call"); + assert.equal(classifyProviderToolRequestType("web_search"), "dynamic_tool_call"); assert.equal(classifyProviderToolRequestType("Task"), "dynamic_tool_call"); }); }); diff --git a/packages/shared/src/providerToolClassification.ts b/packages/shared/src/providerToolClassification.ts index d4145754892..c1052b406f2 100644 --- a/packages/shared/src/providerToolClassification.ts +++ b/packages/shared/src/providerToolClassification.ts @@ -126,13 +126,14 @@ export function classifyProviderToolItemType(input: { } export function classifyProviderToolRequestType(toolName: string): CanonicalRequestType { - if (isReadOnlyProviderToolName(toolName)) { - return "file_read_approval"; - } const itemType = classifyProviderToolItemType({ toolName }); return itemType === "command_execution" ? "command_execution_approval" : itemType === "file_change" ? "file_change_approval" - : "dynamic_tool_call"; + : itemType === "web_search" + ? "dynamic_tool_call" + : isReadOnlyProviderToolName(toolName) + ? "file_read_approval" + : "dynamic_tool_call"; } From fb75c4a26bd8b2cbf309f60d6bd1b87d15cdb5d2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:55:25 +0200 Subject: [PATCH 064/104] Fix checkpoint revert rollback ordering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/CheckpointReactor.test.ts | 84 ++++++++++++++++++- .../orchestration/Layers/CheckpointReactor.ts | 52 ++++++++++-- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index a2ab1346578..839fecb1142 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -419,6 +419,7 @@ describe("CheckpointReactor", () => { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, + checkpointStore, cwd, drain, }; @@ -973,6 +974,81 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("does not roll back provider conversation when filesystem restore fails", async () => { + const harness = await createHarness(); + const createdAt = "2026-01-01T00:00:00.000Z"; + vi.spyOn(harness.checkpointStore, "restoreCheckpoint").mockImplementationOnce(() => + Effect.succeed(false), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-restore-fails"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-restore-fails-diff-1"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-restore-fails-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-restore-fails-diff-2"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-restore-fails-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-restore-fails"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + }); + it("executes provider revert and emits thread.reverted for claude sessions", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); const createdAt = "2026-01-01T00:00:00.000Z"; @@ -1042,7 +1118,7 @@ describe("CheckpointReactor", () => { }); }); - it("does not restore checkpoint files when provider rollback is unsupported", async () => { + it("restores current checkpoint files when provider rollback is unsupported", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("copilot"), rollbackConversation: () => @@ -1088,7 +1164,7 @@ describe("CheckpointReactor", () => { createdAt, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make("cmd-diff-copilot-2"), @@ -1103,7 +1179,7 @@ describe("CheckpointReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-revert-copilot-request"), @@ -1131,7 +1207,7 @@ describe("CheckpointReactor", () => { 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"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 23a91eaaf8f..7b322d09c2a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -692,14 +692,14 @@ const make = Effect.gen(function* () { return; } - const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); - if (rolledBackTurns > 0) { - yield* providerService.rollbackConversation({ - threadId: sessionRuntime.value.threadId, - numTurns: rolledBackTurns, - }); - } + 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, @@ -715,6 +715,44 @@ 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* () { + if (!currentCheckpointRef) { + return `Provider rollback failed after filesystem restore: ${error.message}. Current checkpoint ref is unavailable.`; + } + const restoredCurrent = yield* checkpointStore.restoreCheckpoint({ + cwd: sessionRuntime.value.cwd, + checkpointRef: currentCheckpointRef, + fallbackToHead: currentTurnCount === 0, + }); + if (!restoredCurrent) { + return `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint.`; + } + yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + return `Provider rollback failed after filesystem restore: ${error.message}. Filesystem was restored to the current checkpoint.`; + }), + ), + ) + : 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); From 7e982dc57803a7660681fd9bc4e5152cc2a4099a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 13:02:32 +0200 Subject: [PATCH 065/104] Fix file-read tool name classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/shared/src/providerToolClassification.test.ts | 2 ++ packages/shared/src/providerToolClassification.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts index fabc4bd3322..05bf10fd1e4 100644 --- a/packages/shared/src/providerToolClassification.test.ts +++ b/packages/shared/src/providerToolClassification.test.ts @@ -35,6 +35,8 @@ describe("providerToolClassification", () => { it("classifies approval requests from the same rules", () => { assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("read_file"), "file_read_approval"); + assert.equal(classifyProviderToolRequestType("ReadFile"), "file_read_approval"); assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval"); assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval"); assert.equal(classifyProviderToolRequestType("websearch"), "dynamic_tool_call"); diff --git a/packages/shared/src/providerToolClassification.ts b/packages/shared/src/providerToolClassification.ts index c1052b406f2..b72de311735 100644 --- a/packages/shared/src/providerToolClassification.ts +++ b/packages/shared/src/providerToolClassification.ts @@ -71,6 +71,8 @@ export function isReadOnlyProviderToolName(toolName: string): boolean { return ( normalized === "read" || normalized.includes("read file") || + normalized.includes("read_file") || + normalized.includes("readfile") || normalized.includes("view") || normalized.includes("grep") || normalized.includes("glob") || From 502215450646af2745b970e7c61ea4f3ba3e0554 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 13:03:17 +0200 Subject: [PATCH 066/104] Invalidate workspace cache on rollback recovery failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/CheckpointReactor.test.ts | 102 +++++++++++++++++- .../orchestration/Layers/CheckpointReactor.ts | 33 +++--- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 839fecb1142..486da21c73c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -251,7 +251,8 @@ describe("CheckpointReactor", () => { | OrchestrationEngineService | CheckpointReactor | CheckpointStore.CheckpointStore - | ProjectionSnapshotQuery, + | ProjectionSnapshotQuery + | WorkspaceEntries.WorkspaceEntries, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -354,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); @@ -420,6 +424,7 @@ describe("CheckpointReactor", () => { readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, checkpointStore, + workspaceEntries, cwd, drain, }; @@ -1203,6 +1208,101 @@ describe("CheckpointReactor", () => { ).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"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 7b322d09c2a..7d7e81ac0b3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -726,19 +726,28 @@ const make = Effect.gen(function* () { Effect.as(null), Effect.catch((error) => Effect.gen(function* () { - if (!currentCheckpointRef) { - return `Provider rollback failed after filesystem restore: ${error.message}. Current checkpoint ref is unavailable.`; - } - const restoredCurrent = yield* checkpointStore.restoreCheckpoint({ - cwd: sessionRuntime.value.cwd, - checkpointRef: currentCheckpointRef, - fallbackToHead: currentTurnCount === 0, - }); - if (!restoredCurrent) { - return `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint.`; - } + 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 `Provider rollback failed after filesystem restore: ${error.message}. Filesystem was restored to the current checkpoint.`; + return detail; }), ), ) From 0eb019133f3abbc249bfcf275cc32f07f86f24b4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 03:33:01 +0200 Subject: [PATCH 067/104] Fix Copilot provider permission handling --- .../provider/Layers/CopilotAdapter.test.ts | 132 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 33 +++-- .../provider/Layers/CopilotProvider.test.ts | 4 +- .../src/provider/copilotRuntime.test.ts | 2 +- .../CopilotTextGeneration.test.ts | 4 +- 5 files changed, 152 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index ae0bb61b6e2..63cde32874c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -9,9 +9,9 @@ import type { SessionConfig, SessionEvent, } from "@github/copilot-sdk"; -import { it } from "@effect/vitest"; +import { beforeEach, it } from "@effect/vitest"; import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; -import { beforeEach, vi } from "vitest"; +import { vi } from "vitest"; import { ApprovalRequestId, @@ -161,7 +161,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "denies bootstrap permission requests before the session context exists in approval-required mode", () => Effect.gen(function* () { - runtimeMock.state.createSessionImpl = async (config) => { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { assert.ok(config.onPermissionRequest); const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, @@ -189,7 +189,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "approves bootstrap permission requests before the session context exists in full-access mode", () => Effect.gen(function* () { - runtimeMock.state.createSessionImpl = async (config) => { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { assert.ok(config.onPermissionRequest); const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, @@ -213,11 +213,50 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("only approves bootstrap edit permission requests in auto-accept-edits mode", () => + Effect.gen(function* () { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + assert.ok(config.onPermissionRequest); + const shellResult = await config.onPermissionRequest( + { kind: "shell" } as PermissionRequest, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ); + const writeResult = await config.onPermissionRequest( + { kind: "write" } as PermissionRequest, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ); + assert.deepStrictEqual(shellResult, { kind: "reject" }); + assert.deepStrictEqual(writeResult, { kind: "approve-once" }); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-auto-accept-edits"); + + const session = yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + + assert.equal(session.provider, "copilot"); + assert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.at(-1), [ + { mode: "interactive" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + it.effect( "returns an empty bootstrap user input response before the session context exists", () => Effect.gen(function* () { - runtimeMock.state.createSessionImpl = async (config) => { + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { assert.ok(config.onUserInputRequest); const response = await config.onUserInputRequest( { @@ -382,7 +421,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { it.effect("starts a fresh session when the persisted Copilot resume cursor is missing", () => Effect.gen(function* () { - runtimeMock.state.resumeSessionImpl = async (sessionId) => { + runtimeMock.state.resumeSessionImpl = async (sessionId: string) => { throw new Error( `Request session.resume failed with message: Session not found: ${sessionId}`, ); @@ -1431,7 +1470,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const parserErrorCalls = [ ...consoleErrorSpy.mock.calls, ...consoleLogSpy.mock.calls, - ].filter((args) => args.some((arg) => String(arg).includes("parseLineType"))); + ].filter((args) => args.some((arg: unknown) => String(arg).includes("parseLineType"))); assert.deepStrictEqual(parserErrorCalls, []); const completedTool = runtimeEvents.find( @@ -1675,6 +1714,85 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("prompts for shell permissions in auto-accept-edits mode", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-auto-accept-edits-shell-permission"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "shell", + toolCallId: "tool-shell-auto-accept-edits", + fullCommandText: "git status", + intention: "Check repository status", + commands: [{ identifier: "git", readOnly: true }], + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: true, + }; + const requestId = "permission-shell-auto-accept-edits"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-permission-auto-accept-edits", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: { + kind: "commands", + toolCallId: "tool-shell-auto-accept-edits", + fullCommandText: "git status", + intention: "Check repository status", + commandIdentifiers: ["git"], + canOfferSessionApproval: true, + }, + }, + } as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + assert.equal(opened?.type, "request.opened"); + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { kind: "approve-once" }); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits thread metadata updates from Copilot title changes", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 4007aa4c4ee..400d71efdb1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -393,7 +393,21 @@ function requestedCopilotMode(input: { if (input.interactionMode === "plan") { return "plan"; } - return input.runtimeMode === "approval-required" ? "interactive" : "autopilot"; + 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( @@ -1433,10 +1447,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( promptRequest: eventData.promptRequest, deferred: handler.deferred, }); - if ( - context.session.runtimeMode === "approval-required" && - eventData.resolvedByHook !== true - ) { + if (eventData.resolvedByHook !== true) { yield* emitPermissionRequestOpened( context, context.pendingPermissionBindings.get(requestId)!, @@ -1530,12 +1541,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( request: PermissionRequest, ): Effect.Effect => Effect.gen(function* () { - if (context.session.runtimeMode !== "approval-required") { - return APPROVED_PERMISSION_RESULT; - } 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(); @@ -2431,9 +2442,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context ? onPermissionRequest(context, _request) : Effect.succeed( - input.runtimeMode === "approval-required" - ? DENIED_PERMISSION_RESULT - : APPROVED_PERMISSION_RESULT, + permissionAutoApprovedByRuntimeMode(input.runtimeMode, _request) + ? APPROVED_PERMISSION_RESULT + : DENIED_PERMISSION_RESULT, ), ); }; diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index e75cf76769d..3f9925f55ae 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { it } from "@effect/vitest"; +import { beforeEach, describe, it } from "@effect/vitest"; import { CopilotSettings } from "@t3tools/contracts"; import { DateTime, Effect, Schema } from "effect"; -import { beforeEach, describe, vi } from "vitest"; +import { vi } from "vitest"; import { checkCopilotProviderStatus } from "./CopilotProvider.ts"; diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 61d764f814d..21f5a02c5d6 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { describe, it } from "vitest"; +import { describe, it } from "@effect/vitest"; import { authSnapshotFromCopilotSdk, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index a5f7ecddb27..9ba1ed5ec3f 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -1,9 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { beforeEach, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { vi } from "vitest"; import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { beforeEach, expect, vi } from "vitest"; import { ServerConfig } from "../config.ts"; import { makeCopilotTextGeneration } from "./CopilotTextGeneration.ts"; From 3645a178ee1531c5f5eabb9c75e0c93029c0e0e2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 03:51:44 +0200 Subject: [PATCH 068/104] Fix Copilot runtime after rebase --- .../provider/Layers/CopilotAdapter.test.ts | 43 +++-- .../src/provider/Layers/CopilotAdapter.ts | 24 +-- .../provider/Layers/CopilotProvider.test.ts | 6 +- .../src/provider/Layers/CopilotProvider.ts | 112 ++++++------- .../src/provider/copilotRuntime.test.ts | 122 +++++++------- apps/server/src/provider/copilotRuntime.ts | 149 +++++++++++------- .../CopilotTextGeneration.test.ts | 2 +- .../textGeneration/CopilotTextGeneration.ts | 24 +-- 8 files changed, 269 insertions(+), 213 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 63cde32874c..5367c2639fd 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -99,29 +99,26 @@ vi.mock("../copilotRuntime.ts", async () => { return { ...actual, - createCopilotClient: vi.fn( - () => - ({ - 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, + 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), ), }; }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 400d71efdb1..a87242c1ef8 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -31,6 +31,7 @@ import { } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { classifyProviderToolItemType } from "@t3tools/shared/providerToolClassification"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -2456,22 +2457,23 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); }; - const client = yield* Effect.try({ - try: () => - createCopilotClient({ - settings, - cwd, - ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), - ...(options?.environment ? { env: options.environment } : {}), - logLevel: "error", - }), - catch: (cause) => + const platform = yield* HostProcessPlatform; + const client = yield* createCopilotClient({ + settings, + cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + ...(options?.environment ? { env: options.environment } : {}), + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => processError( input.threadId, detailFromCause(cause, "Failed to configure Copilot client."), cause, ), - }); + ), + ); const baseSessionConfig = { clientName: "t3-code", diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 3f9925f55ae..b464e5d2b07 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -30,9 +30,9 @@ vi.mock("../copilotRuntime.ts", async () => { ...actual, createCopilotClient: vi.fn(() => { if (runtimeMock.state.createClientError) { - throw runtimeMock.state.createClientError; + return Effect.fail(runtimeMock.state.createClientError); } - return { + return Effect.succeed({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), getStatus: vi.fn(async () => ({ @@ -52,7 +52,7 @@ vi.mock("../copilotRuntime.ts", async () => { } return []; }), - }; + }); }), }; }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 2b046bb92ea..050925a9eaa 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -1,4 +1,5 @@ import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { DateTime, Effect } from "effect"; import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; @@ -92,61 +93,62 @@ export function checkCopilotProviderStatus(input: { }); }; - return Effect.acquireUseRelease( - Effect.try({ - try: () => - createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), - ...(input.environment ? { env: input.environment } : {}), - logLevel: "error", - }), - catch: 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 Effect.gen(function* () { + const platform = yield* HostProcessPlatform; - return buildServerProvider({ - driver: PROVIDER, - presentation: COPILOT_PRESENTATION, - enabled: true, - checkedAt, - models: providerModels, - probe: { - installed: true, - version: versionFromCopilotStatus(status), - status: - authSnapshot.status !== "ready" - ? authSnapshot.status + 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 - ? "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)))); + ? {} + : { 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/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 21f5a02c5d6..03e31366ad9 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it } from "@effect/vitest"; +import type { CopilotClientOptions } from "@github/copilot-sdk"; +import * as Effect from "effect/Effect"; import { authSnapshotFromCopilotSdk, @@ -10,9 +13,7 @@ import { resolveBundledCopilotCliPath, } from "./copilotRuntime.ts"; -function assertStdioConnection( - connection: ReturnType["connection"], -) { +function assertStdioConnection(connection: CopilotClientOptions["connection"]) { assert.equal(connection?.kind, "stdio"); return connection; } @@ -138,62 +139,75 @@ describe("buildCopilotClientOptions", () => { assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, undefined); }); - it("strips inherited COPILOT_CLI_PATH and uses the local Copilot CLI shim by default", () => { - const options = 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", - }, - logLevel: "error", - }); + it.layer(NodeServices.layer)("Copilot CLI command resolution", (it) => { + it.effect( + "strips inherited COPILOT_CLI_PATH and uses the local Copilot CLI shim by default", + () => + Effect.gen(function* () { + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "", + serverUrl: "", + customModels: [], + }, + cwd: "/tmp/project", + baseDirectory: "/tmp/t3-copilot-home", + env: { + PATH: "/usr/bin", + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + GITHUB_TOKEN: "github-token", + }, + platform: "darwin", + logLevel: "error", + }); + + const connection = assertStdioConnection(options.connection); + assert.ok(connection.path?.includes("node_modules/.bin/copilot")); + assert.equal(options.workingDirectory, "/tmp/project"); + assert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); + assert.equal(options.logLevel, "error"); + assert.equal(options.mode, "copilot-cli"); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + assert.equal(options.env?.GITHUB_TOKEN, "github-token"); + assert.equal(options.env?.PATH, "/usr/bin"); + }), + ); - const connection = assertStdioConnection(options.connection); - assert.ok(connection.path?.includes("node_modules/.bin/copilot")); - assert.equal(options.workingDirectory, "/tmp/project"); - assert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); - assert.equal(options.logLevel, "error"); - assert.equal(options.mode, "copilot-cli"); - assert.equal(options.env?.COPILOT_CLI_PATH, undefined); - assert.equal(options.env?.GITHUB_TOKEN, "github-token"); - assert.equal(options.env?.PATH, "/usr/bin"); - }); + it.effect("resolves the bundled Copilot CLI shim without relying on PATH", () => + Effect.gen(function* () { + const cliPath = yield* resolveBundledCopilotCliPath({ + cwd: "/tmp/project", + env: { PATH: "/usr/bin" }, + platform: "darwin", + }); - it("resolves the bundled Copilot CLI shim without relying on PATH", () => { - const cliPath = resolveBundledCopilotCliPath({ - cwd: "/tmp/project", - env: { PATH: "/usr/bin" }, - }); + assert.ok(cliPath?.includes("node_modules/.bin/copilot")); + }), + ); - assert.ok(cliPath?.includes("node_modules/.bin/copilot")); - }); + it.effect("prefers the configured binary path over any inherited CLI path override", () => + Effect.gen(function* () { + const configuredBinaryPath = process.execPath; - it("prefers the configured binary path over any inherited CLI path override", () => { - const configuredBinaryPath = process.execPath; - - const options = buildCopilotClientOptions({ - settings: { - enabled: true, - binaryPath: configuredBinaryPath, - serverUrl: "", - customModels: [], - }, - env: { - COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", - }, - }); + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: configuredBinaryPath, + serverUrl: "", + customModels: [], + }, + env: { + COPILOT_CLI_PATH: "/opt/homebrew/bin/copilot", + }, + platform: "darwin", + }); - const connection = assertStdioConnection(options.connection); - assert.equal(connection.path, configuredBinaryPath); - assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + const connection = assertStdioConnection(options.connection); + assert.equal(connection.path, configuredBinaryPath); + assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + }), + ); }); it("omits the generic signed-in user prefix from authenticated Copilot labels", () => { diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 34fdf2caf67..1f7db737085 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -7,6 +7,7 @@ import { type GetStatusResponse, type ModelInfo, } from "@github/copilot-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { accessSync, constants as fsConstants, statSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -19,8 +20,11 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; @@ -60,6 +64,10 @@ export class CopilotProbePromiseError extends Error { } } +class CopilotCliPathResolutionError extends Data.TaggedError("CopilotCliPathResolutionError")<{ + readonly message: string; +}> {} + export function trimOrUndefined(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -113,16 +121,17 @@ function normalizeAuthLabelPart(value: string | null | undefined): string | unde return trimmed ? trimmed.replace(/^@/, "").toLowerCase() : undefined; } -export function createCopilotClient(input: { +export const createCopilotClient = Effect.fn("createCopilotClient")(function* (input: { readonly settings: CopilotSettings; readonly cwd?: string; readonly baseDirectory?: string; readonly env?: Record; + readonly platform: NodeJS.Platform; readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; -}) { - return new CopilotClient(buildCopilotClientOptions(input)); -} +}): Effect.fn.Return { + return new CopilotClient(yield* buildCopilotClientOptions(input)); +}); function isExecutableFile(path: string): boolean { try { @@ -161,7 +170,7 @@ function appendCommaSeparatedValue(value: string | undefined, entry: string): st export function normalizeCopilotRuntimeEnvironment( input: Record, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): Record { const env = { ...input }; @@ -181,31 +190,49 @@ export function normalizeCopilotRuntimeEnvironment( return env; } -function validateConfiguredCopilotCliPath(input: { - readonly settings: CopilotSettings; - readonly env?: Record; -}): string | undefined { - const cliUrl = trimOrUndefined(input.settings.serverUrl); - if (cliUrl) { - return undefined; - } +const resolveCopilotCommandPath = ( + command: string, + input: { + readonly env: Record; + readonly platform: NodeJS.Platform; + }, +) => + resolveCommandPath(command, { env: input.env }).pipe( + Effect.provideService(HostProcessPlatform, input.platform), + Effect.provide(NodeServices.layer), + ); - const cliPath = trimOrUndefined(input.settings.binaryPath); - if (!cliPath) { - return undefined; - } +const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCliPath")( + function* (input: { + readonly settings: CopilotSettings; + readonly env?: Record; + readonly platform: NodeJS.Platform; + }): Effect.fn.Return { + const cliUrl = trimOrUndefined(input.settings.serverUrl); + if (cliUrl) { + return undefined; + } - const env = { - ...process.env, - ...input.env, - }; - const resolvedCommandPath = resolveCommandPath(cliPath, { env }); - if (!resolvedCommandPath) { - throw new Error(`The configured Copilot binary could not be found: ${cliPath}.`); - } + const cliPath = trimOrUndefined(input.settings.binaryPath); + if (!cliPath) { + return undefined; + } - return resolvedCommandPath; -} + const env = { + ...process.env, + ...input.env, + }; + return yield* resolveCopilotCommandPath(cliPath, { env, platform: input.platform }).pipe( + Effect.catchTag("CommandResolutionError", () => + Effect.fail( + new CopilotCliPathResolutionError({ + message: `The configured Copilot binary could not be found: ${cliPath}.`, + }), + ), + ), + ); + }, +); function candidateDirectoryAncestors(directory: string): ReadonlyArray { const directories: string[] = []; @@ -223,41 +250,48 @@ function candidateDirectoryAncestors(directory: string): ReadonlyArray { return directories; } -export function resolveBundledCopilotCliPath(input: { - readonly cwd?: string; - readonly env?: Record; -}): string | undefined { - const moduleDirectory = dirname(fileURLToPath(import.meta.url)); - const candidateRoots = new Set(); - - if (input.cwd) { - candidateRoots.add(input.cwd); - } - candidateRoots.add(process.cwd()); +export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliPath")( + function* (input: { + readonly cwd?: string; + readonly env?: Record; + readonly platform: NodeJS.Platform; + }): Effect.fn.Return { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const candidateRoots = new Set(); + + if (input.cwd) { + candidateRoots.add(input.cwd); + } + candidateRoots.add(process.cwd()); - for (const directory of candidateDirectoryAncestors(moduleDirectory)) { - candidateRoots.add(directory); - } + for (const directory of candidateDirectoryAncestors(moduleDirectory)) { + candidateRoots.add(directory); + } - for (const root of candidateRoots) { - const candidate = join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); - const resolved = resolveCommandPath(candidate, input.env ? { env: input.env } : {}); - if (resolved) { - return resolved; + for (const root of candidateRoots) { + const candidate = join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); + const resolved = yield* resolveCopilotCommandPath(candidate, { + env: input.env ?? process.env, + platform: input.platform, + }).pipe(Effect.catchTag("CommandResolutionError", () => Effect.void)); + if (resolved) { + return resolved; + } } - } - return undefined; -} + return undefined; + }, +); -export function buildCopilotClientOptions(input: { +export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")(function* (input: { readonly settings: CopilotSettings; readonly cwd?: string; readonly baseDirectory?: string; readonly env?: Record; + readonly platform: NodeJS.Platform; readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; -}): CopilotClientOptions { +}): Effect.fn.Return { const cliUrl = trimOrUndefined(input.settings.serverUrl); let env: Record = { ...process.env }; @@ -266,15 +300,20 @@ export function buildCopilotClientOptions(input: { } delete env[COPILOT_CLI_PATH_ENV]; - env = normalizeCopilotRuntimeEnvironment(env); + env = normalizeCopilotRuntimeEnvironment(env, input.platform); - const configuredCliPath = validateConfiguredCopilotCliPath({ + const configuredCliPath = yield* validateConfiguredCopilotCliPath({ settings: input.settings, env, + platform: input.platform, }); const bundledCliPath = !cliUrl && !configuredCliPath - ? resolveBundledCopilotCliPath({ ...(input.cwd ? { cwd: input.cwd } : {}), env }) + ? yield* resolveBundledCopilotCliPath({ + ...(input.cwd ? { cwd: input.cwd } : {}), + env, + platform: input.platform, + }) : undefined; const cliPath = configuredCliPath ?? bundledCliPath; @@ -291,7 +330,7 @@ export function buildCopilotClientOptions(input: { ...(input.logLevel ? { logLevel: input.logLevel } : {}), ...(input.onListModels ? { onListModels: input.onListModels } : {}), }; -} +}); export function versionFromCopilotStatus(status: GetStatusResponse): string | null { return trimOrUndefined(status.version) ?? null; diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 9ba1ed5ec3f..7b9902cee8a 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -67,7 +67,7 @@ vi.mock("../provider/copilotRuntime.ts", async () => { createSession, }; runtimeMock.state.createdClients.push({ input, client }); - return client; + return Effect.succeed(client); }, ), }; diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 4ed96c0b80e..5c13e5a68bf 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -10,6 +10,7 @@ import { } 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"; @@ -166,6 +167,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( readonly settings: CopilotSettings; }): Effect.Effect => Effect.gen(function* () { + const platform = yield* HostProcessPlatform; const clientKey = copilotTextClientKey({ settings: input.settings, cwd: input.cwd, @@ -180,22 +182,22 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( return existing.client; } - const client = yield* Effect.try({ - try: () => - createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), - env: environment, - logLevel: "error", - }), - catch: (cause) => + const client = yield* createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => copilotTextGenerationError( input.operation, detailFromCause(cause, "Failed to configure Copilot client."), cause, ), - }); + ), + ); yield* Effect.tryPromise({ try: () => client.start(), catch: (cause) => From b3cfd970cece9d1b15df218273201515344f68a5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:19:29 +0200 Subject: [PATCH 069/104] Fix Copilot review issues --- .../provider/Layers/CopilotAdapter.test.ts | 58 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 6 +- apps/server/src/provider/copilotRuntime.ts | 5 +- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5367c2639fd..54feee3d552 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -600,6 +600,64 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("returns a session-scoped SDK domain approval for URL acceptForSession", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-url-permission-accept-for-session"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + assert.ok(config.onPermissionRequest); + + const permissionRequest: PermissionRequest = { + kind: "url", + toolCallId: "tool-url-session-approval", + url: "https://docs.github.com/en/copilot", + intention: "Fetch Copilot documentation", + }; + const requestId = "permission-url-session-approval"; + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + + config.onEvent({ + id: "evt-copilot-url-permission-session-approval", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + + const result = yield* Effect.promise(() => resultPromise); + assert.deepStrictEqual(result, { + kind: "approve-for-session", + domain: "docs.github.com", + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect( "renders Copilot Task_complete tool output as assistant text when no assistant message arrives", () => diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index a87242c1ef8..e25062df087 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -810,7 +810,7 @@ function toolLifecycleData(input: { toolCallId: input.toolCallId, ...(input.toolMeta?.toolName ? { toolName: input.toolMeta.toolName } : {}), ...(input.toolMeta?.command ? { command: input.toolMeta.command } : {}), - ...(input.result ? { result: input.result } : {}), + ...(input.result !== undefined ? { result: input.result } : {}), ...(input.error ? { error: input.error } : {}), ...(input.toolTelemetry ? { toolTelemetry: input.toolTelemetry } : {}), }; @@ -1853,7 +1853,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "runtime.warning", payload: { - message: event.data.message.trim(), + message: trimOrUndefined(event.data.message) ?? "", detail: event.data, }, }); @@ -2291,7 +2291,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( data: toolLifecycleData({ toolCallId: event.data.toolCallId, toolMeta, - ...(event.data.result ? { result: event.data.result } : {}), + ...(event.data.result !== undefined ? { result: event.data.result } : {}), ...(event.data.error ? { error: event.data.error } : {}), ...(event.data.toolTelemetry ? { toolTelemetry: event.data.toolTelemetry } : {}), }), diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 1f7db737085..85ddeafaa1c 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -218,10 +218,7 @@ const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCli return undefined; } - const env = { - ...process.env, - ...input.env, - }; + const env = input.env ?? process.env; return yield* resolveCopilotCommandPath(cliPath, { env, platform: input.platform }).pipe( Effect.catchTag("CommandResolutionError", () => Effect.fail( From 6a95eef1d4c7f188b314e8264c678ac403d9a970 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:41:49 +0200 Subject: [PATCH 070/104] Fix Copilot test vitest imports --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 3 +-- apps/server/src/provider/Layers/CopilotProvider.test.ts | 3 +-- apps/server/src/textGeneration/CopilotTextGeneration.test.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 54feee3d552..344f9bb8a0b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -9,9 +9,8 @@ import type { SessionConfig, SessionEvent, } from "@github/copilot-sdk"; -import { beforeEach, it } from "@effect/vitest"; +import { beforeEach, it, vi } from "@effect/vitest"; import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; -import { vi } from "vitest"; import { ApprovalRequestId, diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index b464e5d2b07..f0e10fed280 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,9 +1,8 @@ import assert from "node:assert/strict"; -import { beforeEach, describe, it } from "@effect/vitest"; +import { beforeEach, describe, it, vi } from "@effect/vitest"; import { CopilotSettings } from "@t3tools/contracts"; import { DateTime, Effect, Schema } from "effect"; -import { vi } from "vitest"; import { checkCopilotProviderStatus } from "./CopilotProvider.ts"; diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 7b9902cee8a..85d6183adc1 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { beforeEach, expect, it } from "@effect/vitest"; +import { beforeEach, expect, it, vi } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { vi } from "vitest"; import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; From ebb633eb9c214fbe16fbb16197e28f304d0bd790 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:56:47 +0200 Subject: [PATCH 071/104] Fix Copilot mock test imports --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 3 ++- apps/server/src/provider/Layers/CopilotProvider.test.ts | 3 ++- apps/server/src/textGeneration/CopilotTextGeneration.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 344f9bb8a0b..f9381d42f54 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -9,8 +9,9 @@ import type { SessionConfig, SessionEvent, } from "@github/copilot-sdk"; -import { beforeEach, it, vi } from "@effect/vitest"; +import { beforeEach, it } from "@effect/vitest"; import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; +import { vi } from "vite-plus/test"; import { ApprovalRequestId, diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index f0e10fed280..99993740470 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { beforeEach, describe, it, vi } from "@effect/vitest"; +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"; diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 85d6183adc1..3e6fc1bc41c 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -1,8 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { beforeEach, expect, it, vi } from "@effect/vitest"; +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"; From 5efb0d920eabacb323b418bb85dc1e2fc649932e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 10:48:08 +0200 Subject: [PATCH 072/104] fix copilot settings patch trimming --- packages/contracts/src/settings.test.ts | 6 ++++++ packages/contracts/src/settings.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) 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 5ef94742759..894606bf17b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -515,8 +515,8 @@ const CodexSettingsPatch = Schema.Struct({ const CopilotSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(Schema.String), - serverUrl: Schema.optionalKey(Schema.String), + binaryPath: Schema.optionalKey(TrimmedString), + serverUrl: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); From 64687b44c0b0162b62a41ca5a0d983538d53e8db Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 12:17:26 +0200 Subject: [PATCH 073/104] Use active turn IDs for Copilot diffs --- .../Layers/ProjectionPipeline.ts | 12 +---- .../Layers/ProviderCommandReactor.ts | 2 - .../provider/Layers/CopilotAdapter.test.ts | 16 ++---- .../src/provider/Layers/CopilotAdapter.ts | 7 +-- .../src/state/threadReducer.test.ts | 50 ------------------- .../client-runtime/src/state/threadReducer.ts | 5 -- 6 files changed, 7 insertions(+), 85 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index debc4b45c74..f12df850941 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -755,19 +755,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } - const session = yield* projectionThreadSessionRepository.getByThreadId({ - threadId: event.payload.threadId, - }); - const anotherTurnStillRunning = - Option.isSome(session) && - session.value.status === "running" && - session.value.activeTurnId !== null && - session.value.activeTurnId !== event.payload.turnId; yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: anotherTurnStillRunning - ? existingRow.value.latestTurnId - : event.payload.turnId, + latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0373a8694ae..65b95257bf3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -706,7 +706,6 @@ const make = Effect.gen(function* () { const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( function* (input: { readonly threadId: ThreadId; - readonly threadModelSelection: ModelSelection; readonly cwd: string; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -795,7 +794,6 @@ const make = Effect.gen(function* () { yield* firstTurnAuxiliaryWorker.enqueue( maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, - threadModelSelection: thread.modelSelection, cwd: generationCwd, ...generationInput, }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index f9381d42f54..effd3763591 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1328,7 +1328,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits a file-change diff turn when a Copilot Apply_patch tool completes", () => + 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"); @@ -1413,10 +1413,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { assert.equal(diffEvent?.type, "turn.diff.updated"); if (diffEvent?.type === "turn.diff.updated") { - assert.equal( - String(diffEvent.turnId), - `${String(turn.turnId)}:file-change:tool-apply-patch`, - ); + assert.equal(diffEvent.turnId, turn.turnId); assert.deepStrictEqual(diffEvent.payload, { unifiedDiff: patch, }); @@ -1435,7 +1432,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); it.effect( - "does not emit a file-change diff turn when a Copilot command tool returns control output", + "does not emit an active turn diff when a Copilot command tool returns shell control output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -1545,7 +1542,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); it.effect( - "emits a file-change diff turn when a Copilot command tool returns a unified diff", + "emits an active turn diff when a Copilot command tool returns git unified diff output", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -1640,10 +1637,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { assert.equal(diffEvent?.type, "turn.diff.updated"); if (diffEvent?.type === "turn.diff.updated") { - assert.equal( - String(diffEvent.turnId), - `${String(turn.turnId)}:file-change:tool-command-diff`, - ); + assert.equal(diffEvent.turnId, turn.turnId); assert.deepStrictEqual(diffEvent.payload, { unifiedDiff: diff.trim(), }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index e25062df087..6e0c3b3c545 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -700,11 +700,6 @@ function completedToolDiffText( return parseTurnDiffFilesFromUnifiedDiff(diffCandidate).length > 0 ? diffCandidate : undefined; } -function fileChangeTurnIdForToolCall(parentTurnId: TurnId, toolCallId: string): TurnId { - const normalizedToolCallId = toolCallId.replace(/[^A-Za-z0-9._:-]+/g, "_") || "tool"; - return TurnId.make(`${String(parentTurnId)}:file-change:${normalizedToolCallId}`); -} - function copilotBackgroundTasksList( session: CopilotSession, ): (() => Promise) | undefined { @@ -2302,7 +2297,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( await emitAsync({ ...createBaseEvent({ threadId: context.threadId, - turnId: fileChangeTurnIdForToolCall(turnId, event.data.toolCallId), + turnId, raw: event, }), type: "turn.diff.updated", diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 7dd96d48e3c..94eb1c65370 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -596,56 +596,6 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.latestTurn?.state).toBe("completed"); } }); - - it("does not replace latestTurn while a different turn is running", () => { - const runningTurnId = TurnId.make("turn-2"); - const diffTurnId = TurnId.make("turn-1"); - const threadWithRunningSession: OrchestrationThread = { - ...baseThread, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "copilot", - runtimeMode: "full-access", - activeTurnId: runningTurnId, - lastError: null, - updatedAt: "2026-04-01T11:59:00.000Z", - }, - latestTurn: { - turnId: runningTurnId, - state: "running", - requestedAt: "2026-04-01T11:59:00.000Z", - startedAt: "2026-04-01T11:59:00.000Z", - completedAt: null, - assistantMessageId: null, - }, - }; - - const result = applyThreadDetailEvent(threadWithRunningSession, { - ...baseEventFields, - sequence: 14, - occurredAt: "2026-04-01T12:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.turn-diff-completed", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: diffTurnId, - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("msg-3"), - completedAt: "2026-04-01T12:00:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.checkpoints).toHaveLength(1); - expect(result.thread.latestTurn).toEqual(threadWithRunningSession.latestTurn); - } - }); }); describe("thread.reverted", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ee6d3ffd278..ee290701885 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -387,13 +387,8 @@ export function applyThreadDetailEvent( thread.session?.status === "running" && thread.session.activeTurnId !== null && thread.session.activeTurnId === event.payload.turnId; - const anotherTurnStillRunning = - thread.session?.status === "running" && - thread.session.activeTurnId !== null && - thread.session.activeTurnId !== event.payload.turnId; const latestTurn = !diffTurnStillRunning && - !anotherTurnStillRunning && (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, From 9da124d4749e1741b617a92379658eb0ec9a6c47 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 13:46:34 +0200 Subject: [PATCH 074/104] Tighten composer prompt effort descriptors --- .../src/components/chat/composerProviderState.test.tsx | 10 +++++++--- apps/web/src/components/chat/composerProviderState.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 73bbba188ba..44d7f45ec10 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -165,24 +165,28 @@ describe("getComposerProviderState", () => { ); }); - it("does not treat context tier as promptEffort when no reasoning descriptor exists", () => { + 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" }, ]), ]), prompt: "", - modelOptions: selections(["contextTier", "long_context"]), + modelOptions: selections(["variant", "test"], ["contextTier", "long_context"]), }); expect(state).toEqual({ provider: PROVIDER, promptEffort: null, - modelOptionsForDispatch: selections(["contextTier", "long_context"]), + modelOptionsForDispatch: selections(["variant", "test"], ["contextTier", "long_context"]), }); }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c1b0e71f836..7a9f09ad410 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -17,7 +17,7 @@ import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -const PROMPT_EFFORT_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning", "variant"]); +const PROMPT_EFFORT_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); export type ComposerProviderStateInput = { provider: ProviderDriverKind; From 9c8b973861c04964a94e84362e63b054d25ec66f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:23:03 +0200 Subject: [PATCH 075/104] Handle provider completions without turn ids --- .../orchestrationEngine.integration.test.ts | 56 +++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 51 +++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 27 ++++++--- 4 files changed, 127 insertions(+), 9 deletions(-) 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/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/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9f84c4ec241..bcc4eed1ba3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -360,6 +360,57 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("settles the active turn when completion omits turn id", 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?.turnId === "turn-missing-completion-id" && + entry.latestTurn?.state === "completed", + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.latestTurn?.state).toBe("completed"); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "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 eb177065bb7..51c0a786768 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1216,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; }, ); @@ -1288,10 +1294,15 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const lifecycleEventTurnId = + event.type === "turn.completed" && eventTurnId === undefined + ? (activeTurnId ?? undefined) + : eventTurnId; 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 @@ -1322,12 +1333,12 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": - if (conflictsWithActiveTurn || missingTurnForActiveTurn) { + if (conflictsWithActiveTurn) { return false; } // 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; @@ -1610,7 +1621,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( From ae40c1fe264715fb7316d063ba9a1700038890af Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:23:21 +0200 Subject: [PATCH 076/104] Normalize Copilot tool lifecycle classification --- .../provider/Layers/CopilotAdapter.test.ts | 1308 +++++++++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 633 +++++++- .../Layers/CopilotToolClassification.test.ts | 62 + .../Layers/CopilotToolClassification.ts | 75 +- packages/shared/package.json | 4 - .../src/providerToolClassification.test.ts | 46 - 6 files changed, 1846 insertions(+), 282 deletions(-) create mode 100644 apps/server/src/provider/Layers/CopilotToolClassification.test.ts rename packages/shared/src/providerToolClassification.ts => apps/server/src/provider/Layers/CopilotToolClassification.ts (77%) delete mode 100644 packages/shared/src/providerToolClassification.test.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index effd3763591..872e44ee5bf 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -963,6 +963,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { toolCallId: "tool-read-file", toolName: "Read", arguments: { + kind: "execute", path: "README.md", }, }, @@ -1022,6 +1023,21 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { false, ); + const startedTool = runtimeEvents.find( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-read-file", + ); + assert.equal(startedTool?.type, "item.started"); + if (startedTool?.type === "item.started") { + assert.ok( + startedTool.payload.data !== null && + typeof startedTool.payload.data === "object" && + !Array.isArray(startedTool.payload.data), + ); + const data = startedTool.payload.data as Record; + assert.equal(data.kind, "read"); + } + yield* adapter.stopSession(threadId); }), ); @@ -1431,6 +1447,128 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; + + emit({ + id: "evt-copilot-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", + ); + + assert.equal(startedTool?.type, "item.started"); + if (startedTool?.type === "item.started") { + assert.equal(startedTool.payload.itemType, "file_change"); + assert.equal(startedTool.payload.title, "Applied patch"); + assert.ok( + startedTool.payload.data !== null && + typeof startedTool.payload.data === "object" && + !Array.isArray(startedTool.payload.data), + ); + assert.equal("command" in startedTool.payload.data, false); + const data = startedTool.payload.data as Record; + assert.equal(data.kind, "edit"); + } + assert.equal(completedTool?.type, "item.completed"); + if (completedTool?.type === "item.completed") { + assert.equal(completedTool.payload.itemType, "file_change"); + assert.equal(completedTool.payload.title, "Applied patch"); + assert.ok( + completedTool.payload.data !== null && + typeof completedTool.payload.data === "object" && + !Array.isArray(completedTool.payload.data), + ); + assert.equal("command" in completedTool.payload.data, false); + const data = completedTool.payload.data as Record; + assert.equal(data.kind, "edit"); + } + assert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + assert.equal(diffEvent.turnId, turn.turnId); + assert.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", () => @@ -1647,7 +1785,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("does not emit an empty turn diff when a Copilot write permission is approved", () => + 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"); @@ -1685,7 +1823,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", intention: "Update README", canOfferSessionApproval: true, - } as PermissionRequest; + } as Extract; emit({ id: "evt-copilot-write-permission-turn-start", @@ -1754,10 +1892,14 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal( - runtimeEvents.some((event) => event.type === "turn.diff.updated"), - false, - ); + const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + assert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + assert.equal(String(diffUpdated.turnId), String(turn.turnId)); + assert.deepStrictEqual(diffUpdated.payload, { + unifiedDiff: permissionRequest.diff.trim(), + }); + } yield* adapter.stopSession(threadId); }), @@ -1984,15 +2126,38 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ], }); } + const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); + assert.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", + ); + assert.equal(runningProgress?.type, "task.progress"); + if (runningProgress?.type === "task.progress") { + assert.equal(runningProgress.payload.description, "Exploring provider events"); + assert.equal(runningProgress.payload.summary, "Task running"); + } + const completedTasks = runtimeEvents.filter((event) => event.type === "task.completed"); + assert.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("ignores empty Copilot background task plan updates", () => + it.effect("tracks Copilot background task status changes", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-empty-background-tasks-plan"); + const threadId = asThreadId("copilot-background-task-status-changes"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2001,7 +2166,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - yield* adapter.sendTurn({ + const turn = yield* adapter.sendTurn({ threadId, input: "delegate the investigation", attachments: [], @@ -2014,56 +2179,107 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ - tasks: [], - }); - const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); const timestamp = yield* nowIso; + + 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-empty-background-tasks", + 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-empty-background-tasks-drain-marker", + id: "evt-copilot-background-task-status-completed", timestamp, parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-after-empty-background-tasks", - }, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, } as SessionEvent); - let markerEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - markerEvent = runtimeEvents.find( + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( (event) => - event.type === "session.state.changed" && - event.payload.reason === "Copilot turn started", + event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(markerEvent?.type, "session.state.changed"); - assert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, + 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", + ); + assert.equal(startedEvents.length, 1); + assert.equal(completedEvent?.type, "task.completed"); + if (completedEvent?.type === "task.completed") { + assert.equal(completedEvent.turnId, turn.turnId); + assert.equal(completedEvent.payload.status, "completed"); + assert.equal(completedEvent.payload.summary, "Inspection completed"); + } yield* adapter.stopSession(threadId); }), ); - it.effect("ignores background task change events when Copilot cannot list tasks", () => + it.effect("maps Copilot todo tool input to T3 plan updates", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-background-tasks-missing-list"); + const threadId = asThreadId("copilot-todo-tool-plan-update"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2072,9 +2288,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - yield* adapter.sendTurn({ + const turn = yield* adapter.sendTurn({ threadId, - input: "delegate the investigation", + input: "track todos", attachments: [], }); @@ -2085,61 +2301,65 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - const session = runtimeMock.state.lastSession as unknown as { - rpc: { backgroundTasks?: unknown }; - }; - delete session.rpc.backgroundTasks; - const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ - id: "evt-copilot-background-tasks-without-list", + id: "evt-copilot-todo-turn-start", timestamp, parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-todo-tool", + }, } as SessionEvent); config.onEvent({ - id: "evt-copilot-background-tasks-drain-marker", + id: "evt-copilot-todo-tool-start", timestamp, parentId: null, - type: "assistant.turn_start", + type: "tool.execution_start", data: { - turnId: "sdk-turn-after-background-tasks", + 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 markerEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + let planEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && planEvent === undefined; attempt += 1) { yield* waitForSdkEventQueue(); - markerEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.reason === "Copilot turn started", - ); + planEvent = runtimeEvents.find((event) => event.type === "turn.plan.updated"); } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(markerEvent?.type, "session.state.changed"); - assert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, - ); - assert.equal( - runtimeEvents.find((event) => event.type === "runtime.error"), - undefined, - ); + assert.equal(planEvent?.type, "turn.plan.updated"); + if (planEvent?.type === "turn.plan.updated") { + assert.equal(String(planEvent.turnId), String(turn.turnId)); + assert.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("emits command metadata separately from command output", () => + it.effect("ignores empty Copilot background task plan updates", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-metadata"); + const threadId = asThreadId("copilot-empty-background-tasks-plan"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2150,7 +2370,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.sendTurn({ threadId, - input: "check git status", + input: "delegate the investigation", attachments: [], }); @@ -2161,31 +2381,178 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); + runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + tasks: [], + }); + const config = runtimeMock.state.createSessionConfigs.at(-1); assert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-command-turn-start", + config.onEvent({ + id: "evt-copilot-empty-background-tasks", timestamp, parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command", - }, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, } as SessionEvent); - emit({ - id: "evt-copilot-command-start", + config.onEvent({ + id: "evt-copilot-empty-background-tasks-drain-marker", timestamp, parentId: null, - type: "tool.execution_start", + type: "assistant.turn_start", data: { - toolCallId: "tool-command", - toolName: "bash", - arguments: { - command: "git status --short", - }, + turnId: "sdk-turn-after-empty-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores background task change events when Copilot cannot list tasks", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-background-tasks-missing-list"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "delegate the investigation", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const session = runtimeMock.state.lastSession as unknown as { + rpc: { backgroundTasks?: unknown }; + }; + delete session.rpc.backgroundTasks; + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-background-tasks-without-list", + timestamp, + parentId: null, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-background-tasks-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-background-tasks", + }, + } as SessionEvent); + + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + assert.equal(markerEvent?.type, "session.state.changed"); + assert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + assert.equal( + runtimeEvents.find((event) => event.type === "runtime.error"), + undefined, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits command metadata separately from command output", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-command-metadata"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + yield* adapter.sendTurn({ + threadId, + input: "check git status", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + emit({ + id: "evt-copilot-command-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-command", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-command", + toolName: "bash", + arguments: { + command: "git status --short", + }, }, } as SessionEvent); for ( @@ -2213,9 +2580,14 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } 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", @@ -2223,10 +2595,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + assert.equal(started?.type, "item.started"); + if (started?.type === "item.started") { + assert.equal(started.payload.itemType, "command_execution"); + assert.equal(started.payload.title, "Ran command: git status --short"); + assert.equal(started.payload.detail, "git status --short"); + } + assert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { assert.equal(completed.payload.itemType, "command_execution"); - assert.equal(completed.payload.title, "Ran command"); + assert.equal(completed.payload.title, "Ran command: git status --short"); assert.equal( completed.payload.detail, "M apps/server/src/provider/Layers/CopilotAdapter.ts", @@ -2303,6 +2682,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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) { @@ -2485,10 +2873,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("does not complete a queued turn from the previous Copilot idle event", () => + it.effect("keeps one T3 turn active across multiple Copilot SDK loops until idle", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-queued-turn-idle-correlation"); + const threadId = asThreadId("copilot-multi-sdk-loop-before-idle"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2497,6 +2885,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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))), @@ -2509,60 +2903,62 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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", + id: "evt-copilot-multi-loop-first-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-queued-first", + turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "session.state.changed" && - String(event.turnId) === String(firstTurn.turnId) && - event.payload.state === "running", - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - assert.equal( - runtimeEvents.some( - (event) => - event.type === "session.state.changed" && - String(event.turnId) === String(firstTurn.turnId) && - event.payload.state === "running", - ), - true, - ); - - const secondTurn = yield* adapter.sendTurn({ - threadId, - input: "second prompt", - attachments: [], - }); emit({ - id: "evt-copilot-queued-first-turn-end", + id: "evt-copilot-multi-loop-first-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-queued-first", + turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); emit({ - id: "evt-copilot-queued-first-idle", + 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* waitForSdkEventQueue(); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.completed"), + false, + ); + + emit({ + id: "evt-copilot-multi-loop-idle", timestamp, parentId: null, type: "session.idle", @@ -2576,28 +2972,298 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { attempt < 20 && !runtimeEvents.some( (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); attempt += 1 ) { yield* waitForSdkEventQueue(); } - yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const completionsAfterFirstIdle = runtimeEvents.filter( - (event) => event.type === "turn.completed", - ); - assert.equal( - completionsAfterFirstIdle.filter( - (event) => String(event.turnId) === String(firstTurn.turnId), - ).length, - 1, + const messageCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.itemId === "copilot-message-message-multi-loop-second", ); - assert.equal( - completionsAfterFirstIdle.filter( - (event) => String(event.turnId) === String(secondTurn.turnId), - ).length, - 0, + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + assert.equal(messageCompleted?.type, "item.completed"); + assert.equal(String(messageCompleted?.turnId), String(turn.turnId)); + assert.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); + assert.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", + ); + assert.equal(runtimeWarning, undefined); + assert.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); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + + emit({ + id: "evt-copilot-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"), + ); + + assert.equal(runtimeWarning, undefined); + assert.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); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + assert.equal( + runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ), + true, + ); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-queued-first-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-queued-first", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-queued-first-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* waitForSdkEventQueue(); + + const completionsAfterFirstIdle = runtimeEvents.filter( + (event) => event.type === "turn.completed", + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(firstTurn.turnId), + ).length, + 1, + ); + assert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(secondTurn.turnId), + ).length, + 0, ); const latestEventAfterFirstIdle = runtimeEvents.at(-1); assert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); @@ -2621,6 +3287,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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; @@ -2645,6 +3320,365 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + 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"), + ); + assert.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), + ); + assert.equal(firstCompletion?.type, "turn.completed"); + assert.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); + assert.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), + ); + assert.equal(replayWarnings.length, 0); + assert.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); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + emit({ + id: "evt-copilot-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), + ); + assert.equal(firstTurnCompletions.length, 1); + assert.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; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6e0c3b3c545..bf9671fb244 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -24,13 +24,13 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, type ThreadTokenUsageSnapshot, ThreadId, TurnId, type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { classifyProviderToolItemType } from "@t3tools/shared/providerToolClassification"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; @@ -46,10 +46,15 @@ import { } from "../Errors.ts"; import { type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { createCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; +import { + classifyCopilotToolItemType, + isReadOnlyCopilotToolName, +} from "./CopilotToolClassification.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; type CopilotMode = "interactive" | "plan" | "autopilot"; type CopilotReasoningEffort = NonNullable; @@ -66,8 +71,16 @@ type SessionApprovalDecision = Extract; 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; @@ -83,6 +96,13 @@ type PlanStep = { status: "pending" | "inProgress" | "completed"; }; +interface CopilotTaskState { + readonly id: string; + description: string; + status: CopilotTaskStatus; + taskType: string | undefined; +} + export interface CopilotAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -156,6 +176,7 @@ interface CopilotSessionContext { readonly cwd: string; readonly turns: Array; readonly queuedTurnIds: Array; + readonly turnQueuedAtMsByTurnId: Map; readonly sdkTurnIdsToTurnIds: Map; readonly completedTurnIds: Set; readonly turnUsageByTurnId: Map; @@ -176,10 +197,13 @@ interface CopilotSessionContext { readonly emittedTextByItemId: Map; readonly assistantItemIdByTurnId: Map; readonly pendingTaskCompletionTextByTurnId: Map; + readonly emittedTurnDiffByTurnId: Map; + readonly copilotTasks: Map; readonly turnIdsWithAssistantText: Set; readonly startedItemIds: Set; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; + activeSdkTurnKey: string | undefined; eventChain: Promise; stopped: boolean; } @@ -522,6 +546,16 @@ function sessionApprovalDecisionFromPermissionRequest( } } +function isPermissionCompletionApproved(result: PermissionRequestResult): boolean { + const kind = result.kind as string; + return ( + kind === "approved" || + kind.startsWith("approved-") || + kind === "approve-once" || + kind === "approve-for-session" + ); +} + function permissionSignature(request: SessionPermissionRequest): string { switch (request.kind) { case "shell": @@ -633,7 +667,7 @@ function toolItemType( mcpServerName?: string, arguments_?: unknown, ): ToolMeta["itemType"] { - return classifyProviderToolItemType({ + return classifyCopilotToolItemType({ toolName, ...(mcpServerName ? { mcpServerName } : {}), ...(arguments_ !== undefined ? { arguments: arguments_ } : {}), @@ -648,17 +682,47 @@ function isApplyPatchTool(toolName: string | undefined): boolean { return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "applypatch"; } -function hasApplyPatchEdit(detail: string): boolean { - const normalized = detail.replace(/\r\n/g, "\n").trim(); - if (!normalized.startsWith("*** Begin Patch")) { +function commandLooksLikePatchEdit(command: string | undefined): boolean { + if (!command) { return false; } - return ( - normalized.includes("\n*** Update File: ") || - normalized.includes("\n*** Add File: ") || - normalized.includes("\n*** Delete File: ") || - normalized.includes("\n*** Move to: ") - ); + return /(?:^|\s)apply_patch(?:\s|$)/u.test(command) || command.includes("*** Begin Patch"); +} + +function hasApplyPatchEdit(detail: string): boolean { + return extractApplyPatchEdit(detail) !== undefined; +} + +function extractApplyPatchEdit(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+>$/; @@ -679,6 +743,10 @@ function hasUnifiedDiffShape(detail: string): boolean { ); } +function hasPatchHeaderShape(detail: string): boolean { + return /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+/u.test(detail); +} + function completedToolDiffText( toolMeta: ToolMeta | undefined, detail: string | undefined, @@ -687,10 +755,15 @@ function completedToolDiffText( if (!normalized) { return undefined; } + const applyPatchDiff = + extractApplyPatchEdit(normalized) ?? extractApplyPatchEdit(toolMeta?.command); if (isApplyPatchTool(toolMeta?.toolName)) { - return hasApplyPatchEdit(normalized) ? normalized : undefined; + return applyPatchDiff; + } + if (toolMeta?.itemType === "file_change" && applyPatchDiff) { + return applyPatchDiff; } - if (toolMeta?.itemType !== "command_execution") { + if (toolMeta?.itemType !== "command_execution" && toolMeta?.itemType !== "file_change") { return undefined; } const diffCandidate = stripShellCompletionControlLines(normalized); @@ -721,6 +794,57 @@ function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status } } +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": @@ -736,7 +860,7 @@ function copilotTaskStatusSuffix(status: CopilotTaskStatus): string { function planStepsFromCopilotTasks(tasks: ReadonlyArray): PlanStep[] { return tasks.map((task) => { - const description = trimOrUndefined(task.description) ?? "Task"; + const description = copilotTaskDescription(task); return { step: `${description}${copilotTaskStatusSuffix(task.status)}`, status: normalizeCopilotTaskStatus(task.status), @@ -744,6 +868,47 @@ function planStepsFromCopilotTasks(tasks: ReadonlyArray): PlanS }); } +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" { @@ -787,9 +952,59 @@ function commandFromToolArguments(arguments_: unknown): string | undefined { } function toolLifecycleTitle(toolMeta: ToolMeta | undefined): string { - return toolMeta?.itemType === "command_execution" - ? "Ran command" - : (toolMeta?.toolName ?? "tool"); + 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) || commandLooksLikePatchEdit(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 startedToolDetail(toolMeta: ToolMeta | undefined): string | undefined { + if (toolMeta?.itemType === "command_execution") { + return trimOrUndefined(toolMeta.command); + } + return undefined; +} + +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 = stripShellCompletionControlLines(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: { @@ -800,11 +1015,22 @@ function toolLifecycleData(input: { 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 { - ...input.arguments, + ...argumentsData, + ...(kind ? { kind } : {}), toolCallId: input.toolCallId, ...(input.toolMeta?.toolName ? { toolName: input.toolMeta.toolName } : {}), - ...(input.toolMeta?.command ? { command: input.toolMeta.command } : {}), + ...(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 } : {}), @@ -932,19 +1158,92 @@ function latestTurnId(context: CopilotSessionContext): TurnId | undefined { return context.turns.at(-1)?.id; } -function resolveTurnIdForSdkTurn(context: CopilotSessionContext, sdkTurnId: string): TurnId { - const existing = context.sdkTurnIdsToTurnIds.get(sdkTurnId); +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 nextTurnId = - context.queuedTurnIds.shift() ?? - context.activeTurnId ?? - latestTurnId(context) ?? - TurnId.make(`copilot-turn-${randomUUID()}`); - context.sdkTurnIdsToTurnIds.set(sdkTurnId, nextTurnId); + + 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", @@ -958,7 +1257,10 @@ function resolveTurnIdForEvent( 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 = @@ -972,12 +1274,18 @@ function resolveTurnIdForEvent( return providerItemTurnId; } if (input?.sdkTurnId) { - return resolveTurnIdForSdkTurn(context, input.sdkTurnId); + return resolveTurnIdForSdkTurn(context, input.sdkTurnId, { + timestamp: input.sdkEventTimestamp, + agentId: input.agentId, + }); + } + if (input?.allowActiveFallback === false) { + return undefined; } - if (context.activeSdkTurnId) { - return context.sdkTurnIdsToTurnIds.get(context.activeSdkTurnId) ?? context.activeTurnId; + if (context.activeSdkTurnKey) { + return context.sdkTurnIdsToTurnIds.get(context.activeSdkTurnKey) ?? context.activeTurnId; } - return context.activeTurnId ?? latestTurnId(context); + return context.activeTurnId; } export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( @@ -1116,7 +1424,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( send: ( context: CopilotSessionContext, messageOptions: MessageOptions, - ): Effect.Effect => + ): Effect.Effect => Effect.tryPromise({ try: () => context.sdkSession.send(messageOptions), catch: (cause) => @@ -1195,8 +1503,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( readonly raw?: SessionEvent | undefined; }, ) => { - // Copilot can report both assistant.turn_end and session.idle for the same - // turn; keep the public runtime lifecycle canonical and idempotent. + // 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); @@ -1205,6 +1513,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.completedTurnIds.add(turnId); context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); + clearSdkTurnMappingsForTurn(context, turnId); if (context.activeTurnId === turnId) { context.activeTurnId = undefined; } @@ -1236,6 +1545,43 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + 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 && + !hasApplyPatchEdit(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; @@ -1514,6 +1860,73 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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) { + yield* emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "task.completed", + payload: { + taskId: runtimeTaskId, + status: completedStatus, + ...(copilotTaskCompletionSummary(task) + ? { summary: copilotTaskCompletionSummary(task) } + : {}), + }, + }); + } + context.copilotTasks.set(taskId, { + id: taskId, + description, + status: task.status, + taskType, + }); + } const plan = planStepsFromCopilotTasks(taskList.tasks); if (plan.length === 0) { return; @@ -1916,7 +2329,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } case "assistant.turn_start": { - const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId); + const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { + timestamp: event.timestamp, + agentId: event.agentId, + }); + if (!turnId) { + return; + } updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -1938,6 +2357,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( case "assistant.reasoning_delta": { const turnId = resolveTurnIdForEvent(context, { sdkTurnId: context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, providerItemId: event.data.reasoningId, }); if (!turnId) { @@ -1959,6 +2380,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( case "assistant.reasoning": { const turnId = resolveTurnIdForEvent(context, { sdkTurnId: context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, providerItemId: event.data.reasoningId, }); if (!turnId) { @@ -1998,6 +2421,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, }); @@ -2020,7 +2445,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "assistant.message": { const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, + sdkTurnId: event.data.turnId ?? context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, providerItemId: event.data.messageId, parentProviderItemId: event.data.parentToolCallId, }); @@ -2085,17 +2512,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } case "assistant.turn_end": { - const turnId = context.sdkTurnIdsToTurnIds.get(event.data.turnId) ?? context.activeTurnId; + const sdkTurnKey = sdkTurnMappingKey(event.agentId, event.data.turnId); + const turnId = context.sdkTurnIdsToTurnIds.get(sdkTurnKey); if (!turnId) { return; } - await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); - await emitTurnCompleted(context, turnId, "completed", { - raw: event, - stopReason: null, - }); - if (context.activeSdkTurnId === event.data.turnId) { + if (context.activeSdkTurnKey === sdkTurnKey) { context.activeSdkTurnId = undefined; + context.activeSdkTurnKey = undefined; } return; } @@ -2103,6 +2527,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = resolveTurnIdForEvent(context, { parentProviderItemId: event.data.parentToolCallId, sdkTurnId: context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, }); if (!turnId) { return; @@ -2148,21 +2574,38 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = resolveTurnIdForEvent(context, { providerItemId: event.data.toolCallId, parentProviderItemId: event.data.parentToolCallId, - sdkTurnId: context.activeSdkTurnId, + 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 = - itemType === "command_execution" - ? commandFromToolArguments(event.data.arguments) - : undefined; + const command = commandFromToolArguments(event.data.arguments); const toolMeta: ToolMeta = { toolName: event.data.toolName, itemType, @@ -2185,6 +2628,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( itemType, status: "inProgress", title: toolLifecycleTitle(toolMeta), + ...(startedToolDetail(toolMeta) ? { detail: startedToolDetail(toolMeta) } : {}), data: toolLifecycleData({ toolCallId: event.data.toolCallId, toolMeta, @@ -2198,6 +2642,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = resolveTurnIdForEvent(context, { providerItemId: event.data.toolCallId, sdkTurnId: context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, + allowActiveFallback: false, }); if (!turnId) { return; @@ -2223,6 +2670,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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) { @@ -2249,7 +2699,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const turnId = resolveTurnIdForEvent(context, { providerItemId: event.data.toolCallId, parentProviderItemId: event.data.parentToolCallId, - sdkTurnId: context.activeSdkTurnId, + sdkTurnId: event.data.turnId ?? context.activeSdkTurnId, + sdkEventTimestamp: event.timestamp, + agentId: event.agentId, }); if (!turnId) { return; @@ -2266,10 +2718,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } : undefined; const toolMeta = context.toolMetaById.get(event.data.toolCallId) ?? fallbackToolMeta; - const detail = + const rawDetail = trimOrUndefined(event.data.result?.detailedContent) ?? trimOrUndefined(event.data.result?.content) ?? trimOrUndefined(event.data.error?.message); + const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -2292,18 +2745,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), }, }); - const diffText = event.data.success ? completedToolDiffText(toolMeta, detail) : undefined; + const diffText = event.data.success + ? completedToolDiffText(toolMeta, rawDetail) + : undefined; if (diffText) { - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw: event, - }), - type: "turn.diff.updated", - payload: { - unifiedDiff: diffText, - }, + await emitTurnDiffUpdated({ + context, + turnId, + diffText, + raw: event, }); } const toolItem: CopilotToolExecutionItem = { @@ -2337,6 +2787,25 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, @@ -2544,6 +3013,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, turns: [], queuedTurnIds: [], + turnQueuedAtMsByTurnId: new Map(), sdkTurnIdsToTurnIds: new Map(), completedTurnIds: new Set(), turnUsageByTurnId: new Map(), @@ -2558,10 +3028,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( emittedTextByItemId: new Map(), assistantItemIdByTurnId: new Map(), pendingTaskCompletionTextByTurnId: new Map(), + emittedTurnDiffByTurnId: new Map(), + copilotTasks: new Map(), turnIdsWithAssistantText: new Set(), startedItemIds: new Set(), activeTurnId: undefined, activeSdkTurnId: undefined, + activeSdkTurnKey: undefined, eventChain: Promise.resolve(), stopped: false, }; @@ -2649,10 +3122,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); 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); const shouldPromoteQueuedTurn = - context.activeTurnId === undefined && context.activeSdkTurnId === undefined; + context.activeTurnId === undefined && context.activeSdkTurnKey === undefined; if (shouldPromoteQueuedTurn) { context.activeTurnId = turnId; } @@ -2681,13 +3156,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( mode: "enqueue", }; - yield* copilotSdk.send(context, messageOptions).pipe( + 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; } @@ -2723,6 +3199,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), ), ); + if (trimOrUndefined(providerMessageId)) { + context.turnIdByProviderItemId.set(providerMessageId, turnId); + } return { threadId: input.threadId, @@ -2780,6 +3259,34 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( : 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) => + processError( + threadId, + 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); 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..fb190b8c95a --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts @@ -0,0 +1,62 @@ +import assert 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", () => { + assert.equal(classifyCopilotToolItemType({ toolName: "bash" }), "command_execution"); + assert.equal( + classifyCopilotToolItemType({ toolName: "Task_complete" }), + "collab_agent_tool_call", + ); + assert.equal( + classifyCopilotToolItemType({ + toolName: "update", + arguments: { + path: "README.md", + content: "new content", + }, + }), + "file_change", + ); + assert.equal( + classifyCopilotToolItemType({ + toolName: "run_in_terminal", + arguments: { + command: "apply_patch <<'PATCH'\n*** Begin Patch\n*** Update File: README.md\nPATCH", + }, + }), + "file_change", + ); + assert.equal( + classifyCopilotToolItemType({ + toolName: "execute", + arguments: { + filePath: "README.md", + newString: "updated", + }, + }), + "file_change", + ); + assert.equal(classifyCopilotToolItemType({ toolName: "Read" }), "dynamic_tool_call"); + assert.equal(classifyCopilotToolItemType({ toolName: "web_fetch" }), "web_search"); + assert.equal(classifyCopilotToolItemType({ toolName: "screenshot" }), "image_view"); + assert.equal( + classifyCopilotToolItemType({ toolName: "call_tool", mcpServerName: "github" }), + "mcp_tool_call", + ); + }); + + it("detects read-only Copilot tools", () => { + assert.equal(isReadOnlyCopilotToolName("Read"), true); + assert.equal(isReadOnlyCopilotToolName("read_file"), true); + assert.equal(isReadOnlyCopilotToolName("grep"), true); + assert.equal(isReadOnlyCopilotToolName("edit_file"), false); + assert.equal(isReadOnlyCopilotToolName("bash"), false); + }); +}); diff --git a/packages/shared/src/providerToolClassification.ts b/apps/server/src/provider/Layers/CopilotToolClassification.ts similarity index 77% rename from packages/shared/src/providerToolClassification.ts rename to apps/server/src/provider/Layers/CopilotToolClassification.ts index b72de311735..f44d9b995c4 100644 --- a/packages/shared/src/providerToolClassification.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.ts @@ -1,4 +1,4 @@ -import type { CanonicalRequestType, ToolLifecycleItemType } from "@t3tools/contracts"; +import type { ToolLifecycleItemType } from "@t3tools/contracts"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -41,6 +41,41 @@ function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean { return hasFilePath && hasEditPayload; } +function commandLooksLikeFileChange(command: string | undefined): boolean { + if (!command) { + return false; + } + return /(?:^|\s)apply_patch(?:\s|$)/u.test(command) || command.includes("*** Begin Patch"); +} + +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) => commandLooksLikeFileChange(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(); if ( @@ -66,21 +101,7 @@ function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boole return toolArgumentsLookLikeFileChange(arguments_); } -export function isReadOnlyProviderToolName(toolName: string): boolean { - const normalized = toolName.toLowerCase(); - return ( - normalized === "read" || - normalized.includes("read file") || - normalized.includes("read_file") || - normalized.includes("readfile") || - normalized.includes("view") || - normalized.includes("grep") || - normalized.includes("glob") || - normalized.includes("search") - ); -} - -export function classifyProviderToolItemType(input: { +export function classifyCopilotToolItemType(input: { readonly toolName: string; readonly mcpServerName?: string | undefined; readonly arguments?: unknown; @@ -100,6 +121,12 @@ export function classifyProviderToolItemType(input: { ) { return "collab_agent_tool_call"; } + if ( + toolNameImpliesFileChange(input.toolName, input.arguments) || + toolCommandLooksLikeFileChange(input.arguments) + ) { + return "file_change"; + } if ( normalized.includes("bash") || normalized.includes("shell") || @@ -109,9 +136,6 @@ export function classifyProviderToolItemType(input: { ) { return "command_execution"; } - if (toolNameImpliesFileChange(input.toolName, input.arguments)) { - return "file_change"; - } if ( normalized.includes("websearch") || normalized.includes("web search") || @@ -126,16 +150,3 @@ export function classifyProviderToolItemType(input: { } return "dynamic_tool_call"; } - -export function classifyProviderToolRequestType(toolName: string): CanonicalRequestType { - const itemType = classifyProviderToolItemType({ toolName }); - return itemType === "command_execution" - ? "command_execution_approval" - : itemType === "file_change" - ? "file_change_approval" - : itemType === "web_search" - ? "dynamic_tool_call" - : isReadOnlyProviderToolName(toolName) - ? "file_read_approval" - : "dynamic_tool_call"; -} diff --git a/packages/shared/package.json b/packages/shared/package.json index 28de7729f57..e08844cbfae 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -67,10 +67,6 @@ "types": "./src/toolActivity.ts", "import": "./src/toolActivity.ts" }, - "./providerToolClassification": { - "types": "./src/providerToolClassification.ts", - "import": "./src/providerToolClassification.ts" - }, "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" diff --git a/packages/shared/src/providerToolClassification.test.ts b/packages/shared/src/providerToolClassification.test.ts deleted file mode 100644 index 05bf10fd1e4..00000000000 --- a/packages/shared/src/providerToolClassification.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict"; - -import { describe, it } from "@effect/vitest"; - -import { - classifyProviderToolItemType, - classifyProviderToolRequestType, -} from "./providerToolClassification.ts"; - -describe("providerToolClassification", () => { - it("classifies common provider tools consistently", () => { - assert.equal(classifyProviderToolItemType({ toolName: "bash" }), "command_execution"); - assert.equal( - classifyProviderToolItemType({ toolName: "Task_complete" }), - "collab_agent_tool_call", - ); - assert.equal( - classifyProviderToolItemType({ - toolName: "update", - arguments: { - path: "README.md", - content: "new content", - }, - }), - "file_change", - ); - assert.equal(classifyProviderToolItemType({ toolName: "Read" }), "dynamic_tool_call"); - assert.equal(classifyProviderToolItemType({ toolName: "web_fetch" }), "web_search"); - assert.equal(classifyProviderToolItemType({ toolName: "screenshot" }), "image_view"); - assert.equal( - classifyProviderToolItemType({ toolName: "call_tool", mcpServerName: "github" }), - "mcp_tool_call", - ); - }); - - it("classifies approval requests from the same rules", () => { - assert.equal(classifyProviderToolRequestType("Read"), "file_read_approval"); - assert.equal(classifyProviderToolRequestType("read_file"), "file_read_approval"); - assert.equal(classifyProviderToolRequestType("ReadFile"), "file_read_approval"); - assert.equal(classifyProviderToolRequestType("bash"), "command_execution_approval"); - assert.equal(classifyProviderToolRequestType("edit_file"), "file_change_approval"); - assert.equal(classifyProviderToolRequestType("websearch"), "dynamic_tool_call"); - assert.equal(classifyProviderToolRequestType("web_search"), "dynamic_tool_call"); - assert.equal(classifyProviderToolRequestType("Task"), "dynamic_tool_call"); - }); -}); From 9bd3582fbdd7a131834393afc75ae85f765343f8 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:28:18 +0200 Subject: [PATCH 077/104] Render Copilot task completion as assistant text --- .../provider/Layers/CopilotAdapter.test.ts | 22 ++++++++++++------- .../src/provider/Layers/CopilotAdapter.ts | 10 ++++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 872e44ee5bf..77463d7d699 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -659,7 +659,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); it.effect( - "renders Copilot Task_complete tool output as assistant text when no assistant message arrives", + "renders Copilot Task_complete output as assistant text instead of a tool call", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -768,6 +768,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { messageId: `copilot-task-completion-${String(turn.turnId)}`, content: resultText, }); + assert.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); @@ -787,16 +797,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { delta: resultText, }); } - const completedTool = runtimeEvents.find( + const taskCompleteToolLifecycleEvent = runtimeEvents.find( (event) => - event.type === "item.completed" && - event.payload.itemType === "collab_agent_tool_call" && + (event.type === "item.started" || event.type === "item.completed") && String(event.itemId) === "copilot-tool-tool-task-complete", ); - assert.equal(completedTool?.type, "item.completed"); - if (completedTool?.type === "item.completed") { - assert.equal(completedTool.providerRefs?.providerItemId, "tool-task-complete"); - } + assert.equal(taskCompleteToolLifecycleEvent, undefined); assert.equal( runtimeEvents.some((event) => event.type === "turn.diff.updated"), false, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index bf9671fb244..be3a598a4e6 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2615,6 +2615,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...toolMeta, }); context.turnIdByProviderItemId.set(event.data.toolCallId, turnId); + if (isTaskCompleteTool(event.data.toolName)) { + return; + } context.startedItemIds.add(itemId); await emitAsync({ ...createBaseEvent({ @@ -2723,6 +2726,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( trimOrUndefined(event.data.result?.content) ?? trimOrUndefined(event.data.error?.message); const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); + if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + return; + } await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -2765,9 +2772,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...(detail ? { detail } : {}), }; appendTurnItem(context, turnId, toolItem); - if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { - context.pendingTaskCompletionTextByTurnId.set(turnId, detail); - } return; } case "permission.requested": { From c53747551fe7602499d122b3da8fdea69bb27003 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:45:07 +0200 Subject: [PATCH 078/104] Fix Copilot adapter test formatting --- .../provider/Layers/CopilotAdapter.test.ts | 265 +++++++++--------- 1 file changed, 131 insertions(+), 134 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 77463d7d699..ff7f2c0505a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -658,158 +658,155 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - 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"); + 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", - }); + 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 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 runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); - const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const resultText = - "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; - const timestamp = yield* nowIso; + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const resultText = + "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; + const timestamp = yield* nowIso; - emit({ - id: "evt-copilot-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-1", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-task-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-task-complete", - toolName: "Task_complete", - arguments: {}, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-task-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-task-complete", - success: true, - result: { - content: resultText, - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, + 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); - - 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); - } + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); - const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); - const assistantItem = turnSnapshot.items.find( - (item) => - typeof item === "object" && - item !== null && - "type" in item && - item.type === "assistant_message", - ); - assert.deepStrictEqual(assistantItem, { - type: "assistant_message", - messageId: `copilot-task-completion-${String(turn.turnId)}`, - content: resultText, - }); - assert.equal( - turnSnapshot.items.some( + 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 === "tool_execution", + item.type === "assistant_message", ), - false, ); - + attempt += 1 + ) { yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + thread = yield* adapter.readThread(threadId); + } - const fallbackDelta = runtimeEvents.find( - (event) => - event.type === "content.delta" && event.payload.streamKind === "assistant_text", - ); - assert.equal(fallbackDelta?.type, "content.delta"); - if (fallbackDelta?.type === "content.delta") { - assert.equal( - String(fallbackDelta.itemId), - `copilot-task-completion-${String(turn.turnId)}`, - ); - assert.deepStrictEqual(fallbackDelta.payload, { - streamKind: "assistant_text", - delta: resultText, - }); - } - const taskCompleteToolLifecycleEvent = runtimeEvents.find( - (event) => - (event.type === "item.started" || event.type === "item.completed") && - String(event.itemId) === "copilot-tool-tool-task-complete", - ); - assert.equal(taskCompleteToolLifecycleEvent, undefined); + const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); + assert.ok(turnSnapshot); + const assistantItem = turnSnapshot.items.find( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + item.type === "assistant_message", + ); + assert.deepStrictEqual(assistantItem, { + type: "assistant_message", + messageId: `copilot-task-completion-${String(turn.turnId)}`, + content: resultText, + }); + assert.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", + ); + assert.equal(fallbackDelta?.type, "content.delta"); + if (fallbackDelta?.type === "content.delta") { assert.equal( - runtimeEvents.some((event) => event.type === "turn.diff.updated"), - false, + String(fallbackDelta.itemId), + `copilot-task-completion-${String(turn.turnId)}`, ); + assert.deepStrictEqual(fallbackDelta.payload, { + streamKind: "assistant_text", + delta: resultText, + }); + } + const taskCompleteToolLifecycleEvent = runtimeEvents.find( + (event) => + (event.type === "item.started" || event.type === "item.completed") && + String(event.itemId) === "copilot-tool-tool-task-complete", + ); + assert.equal(taskCompleteToolLifecycleEvent, undefined); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.diff.updated"), + false, + ); - yield* adapter.stopSession(threadId); - }), + yield* adapter.stopSession(threadId); + }), ); it.effect("does not render the file-change completion fallback as assistant text", () => From 170b1df87c562a49e94baa1659551157d04a68bb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:31:00 +0200 Subject: [PATCH 079/104] Refactor Copilot provider cleanup helpers --- .../src/provider/Layers/CopilotAdapter.ts | 277 ++++++------------ .../provider/Layers/CopilotPatchDetection.ts | 69 +++++ .../Layers/CopilotToolClassification.ts | 11 +- .../src/textGeneration/CodexTextGeneration.ts | 177 ++++++----- .../textGeneration/CopilotTextGeneration.ts | 34 +-- .../src/textGeneration/TextGenerationUtils.ts | 54 ++++ 6 files changed, 319 insertions(+), 303 deletions(-) create mode 100644 apps/server/src/provider/Layers/CopilotPatchDetection.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index be3a598a4e6..46916964b4c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -50,6 +50,14 @@ 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"); @@ -64,6 +72,8 @@ type CopilotUserInputResponse = Awaited< ReturnType> >; type SessionPermissionRequestedEvent = Extract; +type SessionStartedEvent = Extract; +type SessionResumedEvent = Extract; type SessionUserInputRequestedEvent = Extract; type SessionUserInputCompletedEvent = Extract; type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; @@ -340,14 +350,6 @@ function appendTurnItem( ensureTurnSnapshot(context, turnId).items.push(item); } -function assistantItemIdsForContext(context: CopilotSessionContext): Map { - const mutable = context as CopilotSessionContext & { - assistantItemIdByTurnId?: Map; - }; - mutable.assistantItemIdByTurnId ??= new Map(); - return mutable.assistantItemIdByTurnId; -} - function processError( threadId: ThreadId, detail: string, @@ -682,71 +684,6 @@ function isApplyPatchTool(toolName: string | undefined): boolean { return toolName?.toLowerCase().replace(/[\s_-]+/g, "") === "applypatch"; } -function commandLooksLikePatchEdit(command: string | undefined): boolean { - if (!command) { - return false; - } - return /(?:^|\s)apply_patch(?:\s|$)/u.test(command) || command.includes("*** Begin Patch"); -} - -function hasApplyPatchEdit(detail: string): boolean { - return extractApplyPatchEdit(detail) !== undefined; -} - -function extractApplyPatchEdit(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+>$/; - -function stripShellCompletionControlLines(detail: string): string { - return detail - .replace(/\r\n/g, "\n") - .split("\n") - .filter((line) => !SHELL_COMPLETION_CONTROL_LINE_PATTERN.test(line.trim())) - .join("\n") - .trim(); -} - -function hasUnifiedDiffShape(detail: string): boolean { - return ( - detail.includes("diff --git ") || - /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+\n@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(detail) - ); -} - -function hasPatchHeaderShape(detail: string): boolean { - return /(?:^|\n)--- [^\n]+\n\+\+\+ [^\n]+/u.test(detail); -} - function completedToolDiffText( toolMeta: ToolMeta | undefined, detail: string | undefined, @@ -756,7 +693,7 @@ function completedToolDiffText( return undefined; } const applyPatchDiff = - extractApplyPatchEdit(normalized) ?? extractApplyPatchEdit(toolMeta?.command); + extractCopilotApplyPatchEdit(normalized) ?? extractCopilotApplyPatchEdit(toolMeta?.command); if (isApplyPatchTool(toolMeta?.toolName)) { return applyPatchDiff; } @@ -766,7 +703,7 @@ function completedToolDiffText( if (toolMeta?.itemType !== "command_execution" && toolMeta?.itemType !== "file_change") { return undefined; } - const diffCandidate = stripShellCompletionControlLines(normalized); + const diffCandidate = stripCopilotShellCompletionControlLines(normalized); if (!hasUnifiedDiffShape(diffCandidate)) { return undefined; } @@ -957,7 +894,7 @@ function toolLifecycleTitle(toolMeta: ToolMeta | undefined): string { return command ? `Ran command: ${truncateSingleLine(command, 96)}` : "Ran command"; } if (toolMeta?.itemType === "file_change") { - return isApplyPatchTool(toolMeta.toolName) || commandLooksLikePatchEdit(toolMeta.command) + return isApplyPatchTool(toolMeta.toolName) || commandLooksLikeCopilotPatchEdit(toolMeta.command) ? "Applied patch" : (toolMeta.toolName ?? "Updated files"); } @@ -988,7 +925,7 @@ function normalizedToolCompletionDetail( return undefined; } if (toolMeta?.itemType === "command_execution") { - const withoutControlLines = stripShellCompletionControlLines(normalized); + const withoutControlLines = stripCopilotShellCompletionControlLines(normalized); return trimOrUndefined(withoutControlLines); } return normalized; @@ -1558,7 +1495,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const hasParsedFiles = parseTurnDiffFilesFromUnifiedDiff(normalizedDiff).length > 0; if ( !hasParsedFiles && - !hasApplyPatchEdit(normalizedDiff) && + !hasCopilotApplyPatchEdit(normalizedDiff) && !hasUnifiedDiffShape(normalizedDiff) && !hasPatchHeaderShape(normalizedDiff) ) { @@ -1616,7 +1553,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } if (input.itemType === "assistant_message" && input.streamKind === "assistant_text") { input.context.turnIdsWithAssistantText.add(input.turnId); - assistantItemIdsForContext(input.context).set(input.turnId, input.itemId); + input.context.assistantItemIdByTurnId.set(input.turnId, input.itemId); } await emitAsync({ ...createBaseEvent({ @@ -1673,7 +1610,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( messageId: itemId, content, }); - assistantItemIdsForContext(context).set(turnId, itemId); + context.assistantItemIdByTurnId.set(turnId, itemId); context.turnIdsWithAssistantText.add(turnId); }; @@ -2051,118 +1988,90 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }); + 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": { - updateProviderSession(context, { - status: "ready", - model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, - ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), - resumeCursor: toCopilotResumeCursor(event.data.sessionId), - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.started", - payload: { - message: "Copilot session started.", - resume: toCopilotResumeCursor(event.data.sessionId), - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.configured", - payload: { - config: { - model: event.data.selectedModel ?? null, - reasoningEffort: event.data.reasoningEffort ?? null, - cwd: event.data.context?.cwd ?? context.cwd, - }, - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: "ready", - reason: "Copilot session ready", - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.started", - payload: { - providerThreadId: event.data.sessionId, - }, + await emitSessionReadyEvents({ + context, + event, + sessionId: event.data.sessionId, + message: "Copilot session started.", + stateReason: "Copilot session ready", }); return; } case "session.resume": { - updateProviderSession(context, { - status: "ready", - model: trimOrUndefined(event.data.selectedModel) ?? context.session.model, - ...(event.data.context?.cwd ? { cwd: event.data.context.cwd } : {}), - resumeCursor: toCopilotResumeCursor(context.sdkSession.sessionId), - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.started", - payload: { - message: "Copilot session resumed.", - resume: toCopilotResumeCursor(context.sdkSession.sessionId), - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.configured", - payload: { - config: { - model: event.data.selectedModel ?? null, - reasoningEffort: event.data.reasoningEffort ?? null, - cwd: event.data.context?.cwd ?? context.cwd, - }, - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "session.state.changed", - payload: { - state: "ready", - reason: "Copilot session resumed", - }, - }); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.started", - payload: { - providerThreadId: context.sdkSession.sessionId, - }, + await emitSessionReadyEvents({ + context, + event, + sessionId: context.sdkSession.sessionId, + message: "Copilot session resumed.", + stateReason: "Copilot session resumed", }); return; } @@ -2431,7 +2340,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); - assistantItemIdsForContext(context).set(turnId, itemId); + context.assistantItemIdByTurnId.set(turnId, itemId); await emitTextDelta({ context, turnId, @@ -2456,7 +2365,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); - assistantItemIdsForContext(context).set(turnId, itemId); + context.assistantItemIdByTurnId.set(turnId, itemId); await emitTextDelta({ context, turnId, 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/CopilotToolClassification.ts b/apps/server/src/provider/Layers/CopilotToolClassification.ts index f44d9b995c4..5d2d1d18e4b 100644 --- a/apps/server/src/provider/Layers/CopilotToolClassification.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.ts @@ -1,5 +1,7 @@ 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); } @@ -41,13 +43,6 @@ function toolArgumentsLookLikeFileChange(arguments_: unknown): boolean { return hasFilePath && hasEditPayload; } -function commandLooksLikeFileChange(command: string | undefined): boolean { - if (!command) { - return false; - } - return /(?:^|\s)apply_patch(?:\s|$)/u.test(command) || command.includes("*** Begin Patch"); -} - function toolCommandLooksLikeFileChange(arguments_: unknown): boolean { if (!isRecord(arguments_)) { return false; @@ -59,7 +54,7 @@ function toolCommandLooksLikeFileChange(arguments_: unknown): boolean { arguments_.commandText, isRecord(arguments_.input) ? arguments_.input.command : undefined, ]; - return candidates.some((candidate) => commandLooksLikeFileChange(trimmedString(candidate))); + return candidates.some((candidate) => commandLooksLikeCopilotPatchEdit(trimmedString(candidate))); } export function isReadOnlyCopilotToolName(toolName: string): boolean { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..abb1c57b6f7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -15,7 +15,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; 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, type TextGenerationShape } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -23,10 +23,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 +118,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: [] }; @@ -295,110 +296,104 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CodexTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, }); - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("CodexTextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: sanitizeFeatureBranchName, + }); + }); - const generated = yield* runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CodexTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; + const generated = yield* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, }); - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + return makePrContentGenerationResult(generated); + }); - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, - }); + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CodexTextGeneration.generateBranchName", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + return makeBranchNameGenerationResult(generated, sanitizeBranchFragment); + }); - const generated = yield* runCodexJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, - }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CodexTextGeneration.generateThreadTitle", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies TextGeneration.ThreadTitleGenerationResult; + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, }); + return makeThreadTitleGenerationResult(generated); + }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; + } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 5c13e5a68bf..9614da7c014 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -24,9 +24,10 @@ import { } from "./TextGenerationPrompts.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, + makeBranchNameGenerationResult, + makeCommitMessageGenerationResult, + makePrContentGenerationResult, + makeThreadTitleGenerationResult, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; @@ -386,13 +387,11 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( modelSelection: input.modelSelection, }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...(input.includeBranch && "branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)) } - : {}), - }; + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: (branch) => sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + }); }); const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( @@ -413,10 +412,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( modelSelection: input.modelSelection, }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; + return makePrContentGenerationResult(generated); }); const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( @@ -435,9 +431,9 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( attachments: input.attachments, }); - return { - branch: sanitizeFeatureBranchName(sanitizeBranchFragment(generated.branch)), - }; + return makeBranchNameGenerationResult(generated, (branch) => + sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + ); }); const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( @@ -456,9 +452,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( attachments: input.attachments, }); - return { - title: sanitizeThreadTitle(generated.title), - }; + return makeThreadTitleGenerationResult(generated); }); return { 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); From 8ea617e1a51e06cbc9b17eda77c77719ac125678 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:31:32 +0200 Subject: [PATCH 080/104] Mark Copilot provider early access --- apps/web/src/components/settings/providerDriverMeta.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 6a672785acb..c6196f3efa5 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -54,6 +54,7 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = value: ProviderDriverKind.make("copilot"), label: "GitHub Copilot", icon: GithubCopilotIcon, + badgeLabel: "Early Access", settingsSchema: CopilotSettings, }, { From c8842b7f7e4076f0f6a717a10f81c82842a7defc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:43:27 +0200 Subject: [PATCH 081/104] Clean up Copilot adapter internals --- .../src/provider/Layers/CopilotAdapter.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 46916964b4c..f4cb19392b7 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -107,10 +107,8 @@ type PlanStep = { }; interface CopilotTaskState { - readonly id: string; description: string; status: CopilotTaskStatus; - taskType: string | undefined; } export interface CopilotAdapterLiveOptions { @@ -909,13 +907,6 @@ function truncateSingleLine(value: string, max = 120): string { return `${singleLine.slice(0, Math.max(1, max - 3)).trimEnd()}...`; } -function startedToolDetail(toolMeta: ToolMeta | undefined): string | undefined { - if (toolMeta?.itemType === "command_execution") { - return trimOrUndefined(toolMeta.command); - } - return undefined; -} - function normalizedToolCompletionDetail( toolMeta: ToolMeta | undefined, detail: string | undefined, @@ -1618,8 +1609,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context: CopilotSessionContext, pending: PendingPermissionBinding, data: SessionPermissionRequestedEvent["data"], - ): Effect.Effect => - emit({ + ): Effect.Effect => { + const detail = permissionDetail(data.permissionRequest); + return emit({ ...createBaseEvent({ threadId: context.threadId, turnId: pending.turnId, @@ -1638,12 +1630,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( type: "request.opened", payload: { requestType: pending.requestType, - ...(permissionDetail(data.permissionRequest) - ? { detail: permissionDetail(data.permissionRequest) } - : {}), + ...(detail ? { detail } : {}), args: data.permissionRequest, }, }); + }; const emitPermissionRequestResolved = ( context: CopilotSessionContext, @@ -1841,6 +1832,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const completedStatus = completedCopilotTaskStatus(task.status); if (completedStatus && previous?.status !== task.status) { + const summary = copilotTaskCompletionSummary(task); yield* emit({ ...createBaseEvent({ threadId: context.threadId, @@ -1851,17 +1843,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( payload: { taskId: runtimeTaskId, status: completedStatus, - ...(copilotTaskCompletionSummary(task) - ? { summary: copilotTaskCompletionSummary(task) } - : {}), + ...(summary ? { summary } : {}), }, }); } context.copilotTasks.set(taskId, { - id: taskId, description, status: task.status, - taskType, }); } const plan = planStepsFromCopilotTasks(taskList.tasks); @@ -2540,7 +2528,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( itemType, status: "inProgress", title: toolLifecycleTitle(toolMeta), - ...(startedToolDetail(toolMeta) ? { detail: startedToolDetail(toolMeta) } : {}), + ...(toolMeta.itemType === "command_execution" && toolMeta.command + ? { detail: toolMeta.command } + : {}), data: toolLifecycleData({ toolCallId: event.data.toolCallId, toolMeta, From 93f155652c975c0239315fc62ad8dc25803e61bf Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 03:30:30 +0200 Subject: [PATCH 082/104] Fix provider review issues --- .../Layers/ProviderRuntimeIngestion.test.ts | 18 +++++++----------- .../Layers/ProviderRuntimeIngestion.ts | 8 ++++---- .../src/provider/Layers/CopilotAdapter.test.ts | 12 ------------ .../src/provider/Layers/CopilotAdapter.ts | 13 +++++++++++++ 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index bcc4eed1ba3..829d275a741 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -360,7 +360,7 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); - it("settles the active turn when completion omits turn id", async () => { + it("ignores active turn completion when completion omits turn id", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -399,16 +399,12 @@ describe("ProviderRuntimeIngestion", () => { status: "completed", }); - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.session?.status === "ready" && - entry.session?.activeTurnId === null && - entry.latestTurn?.turnId === "turn-missing-completion-id" && - entry.latestTurn?.state === "completed", - ); - expect(thread.session?.status).toBe("ready"); - expect(thread.latestTurn?.state).toBe("completed"); + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.activeTurnId).toBe("turn-missing-completion-id"); + expect(thread?.latestTurn?.state).toBe("running"); }); it("applies provider session.state.changed transitions directly", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 51c0a786768..5471f6ec966 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1294,10 +1294,7 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const lifecycleEventTurnId = - event.type === "turn.completed" && eventTurnId === undefined - ? (activeTurnId ?? undefined) - : eventTurnId; + const lifecycleEventTurnId = eventTurnId; const conflictsWithActiveTurn = activeTurnId !== null && @@ -1336,6 +1333,9 @@ const make = Effect.gen(function* () { if (conflictsWithActiveTurn) { return false; } + if (activeTurnId !== null && lifecycleEventTurnId === undefined) { + return false; + } // Only the active turn may close the lifecycle state. if (activeTurnId !== null && lifecycleEventTurnId !== undefined) { return sameId(activeTurnId, lifecycleEventTurnId); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index ff7f2c0505a..532afb8b4e8 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -353,18 +353,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { wasFreeform: true, }); - emit({ - id: "evt-copilot-user-input-completed", - timestamp, - parentId: null, - type: "user_input.completed", - data: { - requestId, - answer: response.answer, - wasFreeform: response.wasFreeform, - }, - } as SessionEvent); - let resolved: ProviderRuntimeEvent | undefined; for (let attempt = 0; attempt < 20 && resolved === undefined; attempt += 1) { yield* waitForSdkEventQueue(); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index f4cb19392b7..21b57661925 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3211,6 +3211,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } 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); }); From 7793d6b4db85fb58efed98414dae34a554973fb6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 03:59:06 +0200 Subject: [PATCH 083/104] Fix CI review follow-ups --- apps/server/package.json | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 50 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 10 +++- pnpm-lock.yaml | 2 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 917c92bf7d5..1b527825128 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,7 +27,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-node": "^0.9.4", + "@ff-labs/fff-node": "0.9.4", "@github/copilot": "1.0.60", "@github/copilot-sdk": "1.0.0", "@opencode-ai/sdk": "^1.3.15", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 829d275a741..0b8a153f30e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -360,7 +360,7 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); - it("ignores active turn completion when completion omits turn id", async () => { + it("ignores unscoped turn completion while provider reports the active turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -407,6 +407,54 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.latestTurn?.state).toBe("running"); }); + 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("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "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 5471f6ec966..62ba4ce4982 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1295,6 +1295,10 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; const lifecycleEventTurnId = eventTurnId; + const providerActiveTurnId = + event.type === "turn.completed" && eventTurnId === undefined + ? yield* getExpectedProviderTurnIdForThread(thread.id) + : undefined; const conflictsWithActiveTurn = activeTurnId !== null && @@ -1333,7 +1337,11 @@ const make = Effect.gen(function* () { if (conflictsWithActiveTurn) { return false; } - if (activeTurnId !== null && lifecycleEventTurnId === undefined) { + if ( + activeTurnId !== null && + lifecycleEventTurnId === undefined && + sameId(activeTurnId, providerActiveTurnId) + ) { return false; } // Only the active turn may close the lifecycle state. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd7aaf7e144..be8f21f8335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,7 +460,7 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@ff-labs/fff-node': - specifier: ^0.9.4 + specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@github/copilot': specifier: 1.0.60 From 8dfbefd6f540be471d250326048e13202f512bc8 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 08:53:30 +0200 Subject: [PATCH 084/104] Fix unscoped provider turn completion --- .../Layers/ProviderRuntimeIngestion.test.ts | 16 +++++++++------- .../Layers/ProviderRuntimeIngestion.ts | 10 ++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 0b8a153f30e..cb508afecda 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -360,7 +360,7 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); - it("ignores unscoped turn completion while provider reports the active turn", async () => { + it("settles unscoped turn completion while provider reports the active turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -399,12 +399,14 @@ describe("ProviderRuntimeIngestion", () => { status: "completed", }); - await harness.drain(); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.status).toBe("running"); - expect(thread?.session?.activeTurnId).toBe("turn-missing-completion-id"); - expect(thread?.latestTurn?.state).toBe("running"); + 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 () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 62ba4ce4982..b098fcb3f95 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1337,12 +1337,10 @@ const make = Effect.gen(function* () { if (conflictsWithActiveTurn) { return false; } - if ( - activeTurnId !== null && - lifecycleEventTurnId === undefined && - sameId(activeTurnId, providerActiveTurnId) - ) { - return false; + if (activeTurnId !== null && lifecycleEventTurnId === undefined) { + return ( + providerActiveTurnId === undefined || sameId(activeTurnId, providerActiveTurnId) + ); } // Only the active turn may close the lifecycle state. if (activeTurnId !== null && lifecycleEventTurnId !== undefined) { From a1be90d767625a2cbbf01bb6edf72bebbf155687 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 08:53:56 +0200 Subject: [PATCH 085/104] Fix Copilot task and abort event handling --- .../provider/Layers/CopilotAdapter.test.ts | 127 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 22 +-- 2 files changed, 127 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 532afb8b4e8..0f714b82301 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -711,6 +711,52 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }, } 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, @@ -782,12 +828,19 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { delta: resultText, }); } - const taskCompleteToolLifecycleEvent = runtimeEvents.find( - (event) => - (event.type === "item.started" || event.type === "item.completed") && - String(event.itemId) === "copilot-tool-tool-task-complete", + const taskCompleteToolIds = new Set([ + "copilot-tool-tool-task-complete", + "copilot-tool-tool-task-complete-empty-success", + "copilot-tool-tool-task-complete-failure", + ]); + assert.equal( + runtimeEvents.some( + (event) => + (event.type === "item.started" || event.type === "item.completed") && + taskCompleteToolIds.has(String(event.itemId)), + ), + false, ); - assert.equal(taskCompleteToolLifecycleEvent, undefined); assert.equal( runtimeEvents.some((event) => event.type === "turn.diff.updated"), false, @@ -797,6 +850,70 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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); + assert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); + yield* waitForSdkEventQueue(); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.aborted").length, 0); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + assert.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"); + assert.equal(abortedEvents.length, 1); + const aborted = abortedEvents[0]; + assert.equal(aborted?.type, "turn.aborted"); + if (aborted?.type === "turn.aborted") { + assert.equal(String(aborted.turnId), String(turn.turnId)); + assert.equal(aborted.payload.reason, "user_initiated"); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "cancelled"); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("does not render the file-change completion fallback as assistant text", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 21b57661925..6b56cda8f32 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2625,8 +2625,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( trimOrUndefined(event.data.result?.content) ?? trimOrUndefined(event.data.error?.message); const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); - if (event.data.success && detail && isTaskCompleteTool(toolMeta?.toolName)) { - context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + if (isTaskCompleteTool(toolMeta?.toolName)) { + if (event.data.success && detail) { + context.pendingTaskCompletionTextByTurnId.set(turnId, detail); + } return; } await emitAsync({ @@ -3116,24 +3118,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { + function* (threadId, _turnId) { const context = yield* requireSessionContext(sessions, threadId); yield* copilotSdk.abort(context); - - const targetTurnId = turnId ?? context.activeTurnId; - if (targetTurnId) { - yield* emit({ - ...createBaseEvent({ - threadId, - turnId: targetTurnId, - }), - type: "turn.aborted", - payload: { - reason: "Interrupted by user.", - }, - }); - } }, ); From 76134c9b5ec0b8035ccb6391752101223d4fdae6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:12:29 +0200 Subject: [PATCH 086/104] fix: align copilot branch with latest main --- .../Layers/CheckpointReactor.test.ts | 4 +- .../provider/Layers/CopilotAdapter.test.ts | 454 +++++++++--------- .../src/provider/Layers/CopilotAdapter.ts | 6 +- .../provider/Layers/CopilotProvider.test.ts | 18 +- .../Layers/CopilotToolClassification.test.ts | 30 +- .../src/provider/copilotRuntime.test.ts | 66 +-- apps/server/src/provider/copilotRuntime.ts | 18 +- .../chat/composerProviderState.test.tsx | 1 - 8 files changed, 300 insertions(+), 297 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 486da21c73c..817993e3604 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1051,7 +1051,7 @@ describe("CheckpointReactor", () => { true, ); expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + 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 () => { @@ -1202,7 +1202,7 @@ describe("CheckpointReactor", () => { true, ); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + 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); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 0f714b82301..b329fe4d1cc 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1,5 +1,5 @@ -import assert from "node:assert/strict"; -import { setTimeout as sleep } from "node:timers/promises"; +import * as NodeAssert from "node:assert/strict"; +import * as NodeTimersPromises from "node:timers/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -37,7 +37,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.make(value); const COPILOT_DRIVER = ProviderDriverKind.make("copilot"); const COPILOT_INSTANCE_ID = ProviderInstanceId.make("copilot"); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const waitForSdkEventQueue = () => Effect.promise(() => sleep(10).then(() => undefined)); +const waitForSdkEventQueue = () => + Effect.promise(() => NodeTimersPromises.setTimeout(10).then(() => undefined)); const runtimeMock = vi.hoisted(() => { const makeSession = () => ({ @@ -159,11 +160,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { () => Effect.gen(function* () { runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config.onPermissionRequest); const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, }); - assert.deepStrictEqual(result, { kind: "reject" }); + NodeAssert.deepStrictEqual(result, { kind: "reject" }); return runtimeMock.state.lastSession as unknown as CopilotSession; }; @@ -177,7 +178,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - assert.equal(session.provider, "copilot"); + NodeAssert.equal(session.provider, "copilot"); yield* adapter.stopSession(threadId); }), ); @@ -187,11 +188,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { () => Effect.gen(function* () { runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config.onPermissionRequest); const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { sessionId: runtimeMock.state.lastSession.sessionId, }); - assert.deepStrictEqual(result, { kind: "approve-once" }); + NodeAssert.deepStrictEqual(result, { kind: "approve-once" }); return runtimeMock.state.lastSession as unknown as CopilotSession; }; @@ -205,7 +206,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "full-access", }); - assert.equal(session.provider, "copilot"); + NodeAssert.equal(session.provider, "copilot"); yield* adapter.stopSession(threadId); }), ); @@ -213,7 +214,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { it.effect("only approves bootstrap edit permission requests in auto-accept-edits mode", () => Effect.gen(function* () { runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config.onPermissionRequest); const shellResult = await config.onPermissionRequest( { kind: "shell" } as PermissionRequest, { @@ -226,8 +227,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { sessionId: runtimeMock.state.lastSession.sessionId, }, ); - assert.deepStrictEqual(shellResult, { kind: "reject" }); - assert.deepStrictEqual(writeResult, { kind: "approve-once" }); + NodeAssert.deepStrictEqual(shellResult, { kind: "reject" }); + NodeAssert.deepStrictEqual(writeResult, { kind: "approve-once" }); return runtimeMock.state.lastSession as unknown as CopilotSession; }; @@ -241,8 +242,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "auto-accept-edits", }); - assert.equal(session.provider, "copilot"); - assert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.at(-1), [ + NodeAssert.equal(session.provider, "copilot"); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.at(-1), [ { mode: "interactive" }, ]); yield* adapter.stopSession(threadId); @@ -254,7 +255,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { () => Effect.gen(function* () { runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - assert.ok(config.onUserInputRequest); + NodeAssert.ok(config.onUserInputRequest); const response = await config.onUserInputRequest( { question: "How should Copilot continue?", @@ -263,7 +264,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, { sessionId: runtimeMock.state.lastSession.sessionId }, ); - assert.deepStrictEqual(response, { + NodeAssert.deepStrictEqual(response, { answer: "", wasFreeform: true, }); @@ -280,7 +281,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - assert.equal(session.provider, "copilot"); + NodeAssert.equal(session.provider, "copilot"); yield* adapter.stopSession(threadId); }), ); @@ -305,8 +306,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - assert.ok(config.onUserInputRequest); + 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"; @@ -339,16 +340,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "user-input.requested" && String(event.requestId) === requestId, ); } - assert.equal(requested?.type, "user-input.requested"); + NodeAssert.equal(requested?.type, "user-input.requested"); if (requested?.type === "user-input.requested") { - assert.equal(requested.providerRefs?.providerRequestId, requestId); + NodeAssert.equal(requested.providerRefs?.providerRequestId, requestId); } yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requestId), { answer: "Use a custom answer", }); const response = yield* Effect.promise(() => responsePromise); - assert.deepStrictEqual(response, { + NodeAssert.deepStrictEqual(response, { answer: "Use a custom answer", wasFreeform: true, }); @@ -362,13 +363,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(resolved?.type, "user-input.resolved"); + NodeAssert.equal(resolved?.type, "user-input.resolved"); if (resolved?.type === "user-input.resolved") { - assert.equal(resolved.providerRefs?.providerRequestId, requestId); - assert.deepStrictEqual(resolved.payload.answers, { + NodeAssert.equal(resolved.providerRefs?.providerRequestId, requestId); + NodeAssert.deepStrictEqual(resolved.payload.answers, { answer: "Use a custom answer", }); - assert.equal("wasFreeform" in resolved.payload.answers, false); + NodeAssert.equal("wasFreeform" in resolved.payload.answers, false); } yield* adapter.stopSession(threadId); @@ -396,9 +397,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.equal(config?.model, "claude-sonnet-4.6"); - assert.equal(config?.reasoningEffort, "high"); - assert.equal(config?.contextTier, "long_context"); + NodeAssert.equal(config?.model, "claude-sonnet-4.6"); + NodeAssert.equal(config?.reasoningEffort, "high"); + NodeAssert.equal(config?.contextTier, "long_context"); yield* adapter.stopSession(threadId); }), @@ -425,11 +426,14 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }); - assert.equal(runtimeMock.state.resumeSessionCalls.length, 1); - assert.equal(runtimeMock.state.resumeSessionCalls[0]?.sessionId, "missing-copilot-session"); - assert.equal(runtimeMock.state.createSessionConfigs.length, 1); - assert.equal(runtimeMock.state.createSessionConfigs[0]?.sessionId, threadId); - assert.deepEqual(session.resumeCursor, { + 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, }); @@ -462,7 +466,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }); - assert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls.at(-1), [ + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls.at(-1), [ "claude-sonnet-4.6", { contextTier: "long_context" }, ]); @@ -491,8 +495,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); const permissionRequest: PermissionRequest = { kind: "shell", @@ -540,7 +544,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); const result = yield* Effect.promise(() => resultPromise); - assert.deepStrictEqual(result, { + NodeAssert.deepStrictEqual(result, { kind: "approve-for-session", approval: { kind: "commands", @@ -555,11 +559,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "request.resolved" && String(event.requestId) === requestId, ); } - assert.equal(requestResolved?.type, "request.resolved"); + NodeAssert.equal(requestResolved?.type, "request.resolved"); if (requestResolved?.type === "request.resolved") { - assert.equal(requestResolved.payload.requestType, "command_execution_approval"); - assert.equal(requestResolved.payload.decision, "acceptForSession"); - assert.deepStrictEqual(requestResolved.payload.resolution, result); + NodeAssert.equal(requestResolved.payload.requestType, "command_execution_approval"); + NodeAssert.equal(requestResolved.payload.decision, "acceptForSession"); + NodeAssert.deepStrictEqual(requestResolved.payload.resolution, result); } config.onEvent({ @@ -576,12 +580,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const resolvedEvents = runtimeEvents.filter( (event) => event.type === "request.resolved" && String(event.requestId) === requestId, ); - assert.equal(resolvedEvents.length, 1); + NodeAssert.equal(resolvedEvents.length, 1); const duplicateReply = yield* Effect.flip( adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "acceptForSession"), ); - assert.match(duplicateReply.message, /Unknown pending permission request/); + NodeAssert.match(duplicateReply.message, /Unknown pending permission request/); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); yield* adapter.stopSession(threadId); @@ -601,8 +605,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); const permissionRequest: PermissionRequest = { kind: "url", @@ -637,7 +641,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); const result = yield* Effect.promise(() => resultPromise); - assert.deepStrictEqual(result, { + NodeAssert.deepStrictEqual(result, { kind: "approve-for-session", domain: "docs.github.com", }); @@ -672,7 +676,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + 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```"; @@ -787,7 +791,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); + NodeAssert.ok(turnSnapshot); const assistantItem = turnSnapshot.items.find( (item) => typeof item === "object" && @@ -795,12 +799,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItem, { + NodeAssert.deepStrictEqual(assistantItem, { type: "assistant_message", messageId: `copilot-task-completion-${String(turn.turnId)}`, content: resultText, }); - assert.equal( + NodeAssert.equal( turnSnapshot.items.some( (item) => typeof item === "object" && @@ -817,13 +821,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const fallbackDelta = runtimeEvents.find( (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", ); - assert.equal(fallbackDelta?.type, "content.delta"); + NodeAssert.equal(fallbackDelta?.type, "content.delta"); if (fallbackDelta?.type === "content.delta") { - assert.equal( + NodeAssert.equal( String(fallbackDelta.itemId), `copilot-task-completion-${String(turn.turnId)}`, ); - assert.deepStrictEqual(fallbackDelta.payload, { + NodeAssert.deepStrictEqual(fallbackDelta.payload, { streamKind: "assistant_text", delta: resultText, }); @@ -833,7 +837,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "copilot-tool-tool-task-complete-empty-success", "copilot-tool-tool-task-complete-failure", ]); - assert.equal( + NodeAssert.equal( runtimeEvents.some( (event) => (event.type === "item.started" || event.type === "item.completed") && @@ -841,7 +845,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ), false, ); - assert.equal( + NodeAssert.equal( runtimeEvents.some((event) => event.type === "turn.diff.updated"), false, ); @@ -876,12 +880,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* adapter.interruptTurn(threadId, turn.turnId); - assert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); + NodeAssert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); yield* waitForSdkEventQueue(); - assert.equal(runtimeEvents.filter((event) => event.type === "turn.aborted").length, 0); + NodeAssert.equal(runtimeEvents.filter((event) => event.type === "turn.aborted").length, 0); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-abort", @@ -896,18 +900,18 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const abortedEvents = runtimeEvents.filter((event) => event.type === "turn.aborted"); - assert.equal(abortedEvents.length, 1); + NodeAssert.equal(abortedEvents.length, 1); const aborted = abortedEvents[0]; - assert.equal(aborted?.type, "turn.aborted"); + NodeAssert.equal(aborted?.type, "turn.aborted"); if (aborted?.type === "turn.aborted") { - assert.equal(String(aborted.turnId), String(turn.turnId)); - assert.equal(aborted.payload.reason, "user_initiated"); + NodeAssert.equal(String(aborted.turnId), String(turn.turnId)); + NodeAssert.equal(aborted.payload.reason, "user_initiated"); } const completed = runtimeEvents.find((event) => event.type === "turn.completed"); - assert.equal(completed?.type, "turn.completed"); + NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { - assert.equal(completed.payload.state, "cancelled"); + NodeAssert.equal(completed.payload.state, "cancelled"); } yield* adapter.stopSession(threadId); @@ -940,7 +944,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1002,7 +1006,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const thread = yield* adapter.readThread(threadId); const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); + NodeAssert.ok(turnSnapshot); const assistantItems = turnSnapshot.items.filter( (item) => typeof item === "object" && @@ -1010,8 +1014,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItems, []); - assert.equal( + NodeAssert.deepStrictEqual(assistantItems, []); + NodeAssert.equal( runtimeEvents.some( (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", @@ -1049,7 +1053,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1114,7 +1118,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const thread = yield* adapter.readThread(threadId); const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); + NodeAssert.ok(turnSnapshot); const assistantItems = turnSnapshot.items.filter( (item) => typeof item === "object" && @@ -1122,8 +1126,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItems, []); - assert.equal( + NodeAssert.deepStrictEqual(assistantItems, []); + NodeAssert.equal( runtimeEvents.some( (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", @@ -1135,15 +1139,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-read-file", ); - assert.equal(startedTool?.type, "item.started"); + NodeAssert.equal(startedTool?.type, "item.started"); if (startedTool?.type === "item.started") { - assert.ok( + NodeAssert.ok( startedTool.payload.data !== null && typeof startedTool.payload.data === "object" && !Array.isArray(startedTool.payload.data), ); const data = startedTool.payload.data as Record; - assert.equal(data.kind, "read"); + NodeAssert.equal(data.kind, "read"); } yield* adapter.stopSession(threadId); @@ -1176,7 +1180,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1240,7 +1244,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const thread = yield* adapter.readThread(threadId); const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); + NodeAssert.ok(turnSnapshot); const assistantItems = turnSnapshot.items.filter( (item) => typeof item === "object" && @@ -1248,7 +1252,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItems, []); + NodeAssert.deepStrictEqual(assistantItems, []); yield* adapter.stopSession(threadId); }), @@ -1280,7 +1284,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1342,7 +1346,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.ok( + NodeAssert.ok( runtimeEvents.some( (event) => event.type === "item.completed" && @@ -1353,7 +1357,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const thread = yield* adapter.readThread(threadId); const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - assert.ok(turnSnapshot); + NodeAssert.ok(turnSnapshot); const assistantItems = turnSnapshot.items.filter( (item) => typeof item === "object" && @@ -1361,7 +1365,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { "type" in item && item.type === "assistant_message", ); - assert.deepStrictEqual(assistantItems, []); + NodeAssert.deepStrictEqual(assistantItems, []); yield* adapter.stopSession(threadId); }), @@ -1393,7 +1397,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1443,7 +1447,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal( + NodeAssert.equal( runtimeEvents.some((event) => event.type === "turn.diff.updated"), false, ); @@ -1478,7 +1482,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + 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"; @@ -1535,10 +1539,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(diffEvent?.type, "turn.diff.updated"); + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); if (diffEvent?.type === "turn.diff.updated") { - assert.equal(diffEvent.turnId, turn.turnId); - assert.deepStrictEqual(diffEvent.payload, { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.deepStrictEqual(diffEvent.payload, { unifiedDiff: patch, }); } @@ -1549,7 +1553,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { event.payload.itemType === "file_change" && String(event.itemId) === "copilot-tool-tool-apply-patch", ); - assert.equal(completedTool?.type, "item.completed"); + NodeAssert.equal(completedTool?.type, "item.completed"); yield* adapter.stopSession(threadId); }), @@ -1581,7 +1585,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + 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"; @@ -1641,36 +1645,36 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { String(event.itemId) === "copilot-tool-tool-terminal-patch", ); - assert.equal(startedTool?.type, "item.started"); + NodeAssert.equal(startedTool?.type, "item.started"); if (startedTool?.type === "item.started") { - assert.equal(startedTool.payload.itemType, "file_change"); - assert.equal(startedTool.payload.title, "Applied patch"); - assert.ok( + 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), ); - assert.equal("command" in startedTool.payload.data, false); + NodeAssert.equal("command" in startedTool.payload.data, false); const data = startedTool.payload.data as Record; - assert.equal(data.kind, "edit"); + NodeAssert.equal(data.kind, "edit"); } - assert.equal(completedTool?.type, "item.completed"); + NodeAssert.equal(completedTool?.type, "item.completed"); if (completedTool?.type === "item.completed") { - assert.equal(completedTool.payload.itemType, "file_change"); - assert.equal(completedTool.payload.title, "Applied patch"); - assert.ok( + 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), ); - assert.equal("command" in completedTool.payload.data, false); + NodeAssert.equal("command" in completedTool.payload.data, false); const data = completedTool.payload.data as Record; - assert.equal(data.kind, "edit"); + NodeAssert.equal(data.kind, "edit"); } - assert.equal(diffEvent?.type, "turn.diff.updated"); + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); if (diffEvent?.type === "turn.diff.updated") { - assert.equal(diffEvent.turnId, turn.turnId); - assert.equal(diffEvent.payload.unifiedDiff, patch); + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.equal(diffEvent.payload.unifiedDiff, patch); } yield* adapter.stopSession(threadId); @@ -1708,7 +1712,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -1760,7 +1764,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal( + NodeAssert.equal( runtimeEvents.some((event) => event.type === "turn.diff.updated"), false, ); @@ -1769,7 +1773,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ...consoleErrorSpy.mock.calls, ...consoleLogSpy.mock.calls, ].filter((args) => args.some((arg: unknown) => String(arg).includes("parseLineType"))); - assert.deepStrictEqual(parserErrorCalls, []); + NodeAssert.deepStrictEqual(parserErrorCalls, []); const completedTool = runtimeEvents.find( (event) => @@ -1777,7 +1781,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { event.payload.itemType === "command_execution" && String(event.itemId) === "copilot-tool-tool-command", ); - assert.equal(completedTool?.type, "item.completed"); + NodeAssert.equal(completedTool?.type, "item.completed"); yield* adapter.stopSession(threadId); } finally { @@ -1815,7 +1819,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; const diff = [ @@ -1881,10 +1885,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(diffEvent?.type, "turn.diff.updated"); + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); if (diffEvent?.type === "turn.diff.updated") { - assert.equal(diffEvent.turnId, turn.turnId); - assert.deepStrictEqual(diffEvent.payload, { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.deepStrictEqual(diffEvent.payload, { unifiedDiff: diff.trim(), }); } @@ -1919,8 +1923,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; const requestId = "permission-write-readme"; @@ -1967,15 +1971,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "request.opened" && String(event.requestId) === requestId, ); } - assert.equal(opened?.type, "request.opened"); + NodeAssert.equal(opened?.type, "request.opened"); if (opened?.type === "request.opened") { - assert.equal(opened.payload.requestType, "file_change_approval"); - assert.equal(String(opened.turnId), String(turn.turnId)); + 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); - assert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); emit({ id: "evt-copilot-write-permission-completed", @@ -2001,10 +2005,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - assert.equal(diffUpdated?.type, "turn.diff.updated"); + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); if (diffUpdated?.type === "turn.diff.updated") { - assert.equal(String(diffUpdated.turnId), String(turn.turnId)); - assert.deepStrictEqual(diffUpdated.payload, { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(diffUpdated.payload, { unifiedDiff: permissionRequest.diff.trim(), }); } @@ -2033,8 +2037,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); - assert.ok(config.onPermissionRequest); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); const permissionRequest: PermissionRequest = { kind: "shell", @@ -2081,11 +2085,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "request.opened" && String(event.requestId) === requestId, ); } - assert.equal(opened?.type, "request.opened"); + NodeAssert.equal(opened?.type, "request.opened"); yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); const result = yield* Effect.promise(() => resultPromise); - assert.deepStrictEqual(result, { kind: "approve-once" }); + NodeAssert.deepStrictEqual(result, { kind: "approve-once" }); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); yield* adapter.stopSession(threadId); @@ -2112,7 +2116,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-title-change", @@ -2131,10 +2135,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(titleEvent?.type, "thread.metadata.updated"); + NodeAssert.equal(titleEvent?.type, "thread.metadata.updated"); if (titleEvent?.type === "thread.metadata.updated") { - assert.equal(titleEvent.threadId, threadId); - assert.deepStrictEqual(titleEvent.payload, { + NodeAssert.equal(titleEvent.threadId, threadId); + NodeAssert.deepStrictEqual(titleEvent.payload, { name: "Implement Copilot thread titles", }); } @@ -2203,7 +2207,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-background-tasks", @@ -2221,11 +2225,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(planEvent?.type, "turn.plan.updated"); + NodeAssert.equal(planEvent?.type, "turn.plan.updated"); if (planEvent?.type === "turn.plan.updated") { - assert.equal(planEvent.threadId, threadId); - assert.equal(String(planEvent.turnId), String(turn.turnId)); - assert.deepStrictEqual(planEvent.payload, { + 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" }, @@ -2235,7 +2239,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); } const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); - assert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ + NodeAssert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ "task-explore-1", "task-review-1", "task-shell-1", @@ -2244,13 +2248,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "task.progress" && String(event.payload.taskId) === "task-explore-1", ); - assert.equal(runningProgress?.type, "task.progress"); + NodeAssert.equal(runningProgress?.type, "task.progress"); if (runningProgress?.type === "task.progress") { - assert.equal(runningProgress.payload.description, "Exploring provider events"); - assert.equal(runningProgress.payload.summary, "Task running"); + NodeAssert.equal(runningProgress.payload.description, "Exploring provider events"); + NodeAssert.equal(runningProgress.payload.summary, "Task running"); } const completedTasks = runtimeEvents.filter((event) => event.type === "task.completed"); - assert.deepStrictEqual( + NodeAssert.deepStrictEqual( completedTasks.map((event) => [String(event.payload.taskId), event.payload.status]).sort(), [ ["task-review-1", "failed"], @@ -2288,7 +2292,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ @@ -2372,12 +2376,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", ); - assert.equal(startedEvents.length, 1); - assert.equal(completedEvent?.type, "task.completed"); + NodeAssert.equal(startedEvents.length, 1); + NodeAssert.equal(completedEvent?.type, "task.completed"); if (completedEvent?.type === "task.completed") { - assert.equal(completedEvent.turnId, turn.turnId); - assert.equal(completedEvent.payload.status, "completed"); - assert.equal(completedEvent.payload.summary, "Inspection completed"); + NodeAssert.equal(completedEvent.turnId, turn.turnId); + NodeAssert.equal(completedEvent.payload.status, "completed"); + NodeAssert.equal(completedEvent.payload.summary, "Inspection completed"); } yield* adapter.stopSession(threadId); @@ -2410,7 +2414,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-todo-turn-start", @@ -2447,10 +2451,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(planEvent?.type, "turn.plan.updated"); + NodeAssert.equal(planEvent?.type, "turn.plan.updated"); if (planEvent?.type === "turn.plan.updated") { - assert.equal(String(planEvent.turnId), String(turn.turnId)); - assert.deepStrictEqual(planEvent.payload, { + NodeAssert.equal(String(planEvent.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(planEvent.payload, { explanation: "Copilot Todos", plan: [ { step: "Inspect adapter", status: "completed" }, @@ -2494,7 +2498,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-empty-background-tasks", @@ -2525,8 +2529,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(markerEvent?.type, "session.state.changed"); - assert.equal( + NodeAssert.equal(markerEvent?.type, "session.state.changed"); + NodeAssert.equal( runtimeEvents.find((event) => event.type === "turn.plan.updated"), undefined, ); @@ -2566,7 +2570,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { delete session.rpc.backgroundTasks; const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-background-tasks-without-list", @@ -2597,12 +2601,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(markerEvent?.type, "session.state.changed"); - assert.equal( + NodeAssert.equal(markerEvent?.type, "session.state.changed"); + NodeAssert.equal( runtimeEvents.find((event) => event.type === "turn.plan.updated"), undefined, ); - assert.equal( + NodeAssert.equal( runtimeEvents.find((event) => event.type === "runtime.error"), undefined, ); @@ -2637,7 +2641,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -2703,22 +2707,22 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(started?.type, "item.started"); + NodeAssert.equal(started?.type, "item.started"); if (started?.type === "item.started") { - assert.equal(started.payload.itemType, "command_execution"); - assert.equal(started.payload.title, "Ran command: git status --short"); - assert.equal(started.payload.detail, "git status --short"); + 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"); } - assert.equal(completed?.type, "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { - assert.equal(completed.payload.itemType, "command_execution"); - assert.equal(completed.payload.title, "Ran command: git status --short"); - assert.equal( + 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", ); - assert.deepStrictEqual(completed.payload.data, { + NodeAssert.deepStrictEqual(completed.payload.data, { toolCallId: "tool-command", toolName: "bash", command: "git status --short", @@ -2758,7 +2762,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -2809,12 +2813,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); const toolProgress = runtimeEvents.find((event) => event.type === "tool.progress"); - assert.equal(runtimeError, undefined); - assert.equal(toolProgress, undefined); - assert.equal(completed?.type, "turn.completed"); + NodeAssert.equal(runtimeError, undefined); + NodeAssert.equal(toolProgress, undefined); + NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { - assert.equal(String(completed.turnId), String(turn.turnId)); - assert.equal(completed.payload.state, "completed"); + NodeAssert.equal(String(completed.turnId), String(turn.turnId)); + NodeAssert.equal(completed.payload.state, "completed"); } yield* adapter.stopSession(threadId); @@ -2847,7 +2851,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ id: "evt-copilot-session-error-turn-start", @@ -2879,11 +2883,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); - assert.equal(runtimeError?.type, "runtime.error"); - assert.equal(completed?.type, "turn.completed"); + NodeAssert.equal(runtimeError?.type, "runtime.error"); + NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { - assert.equal(completed.payload.state, "failed"); - assert.equal(completed.payload.errorMessage, "Copilot runtime crashed"); + NodeAssert.equal(completed.payload.state, "failed"); + NodeAssert.equal(completed.payload.errorMessage, "Copilot runtime crashed"); } yield* adapter.stopSession(threadId); @@ -2916,7 +2920,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -2971,10 +2975,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const completions = runtimeEvents.filter( (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - assert.equal(completions.length, 1); - assert.equal(completions[0]?.type, "turn.completed"); + NodeAssert.equal(completions.length, 1); + NodeAssert.equal(completions[0]?.type, "turn.completed"); if (completions[0]?.type === "turn.completed") { - assert.equal(completions[0].payload.state, "completed"); + NodeAssert.equal(completions[0].payload.state, "completed"); } yield* adapter.stopSession(threadId); @@ -3007,7 +3011,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3060,7 +3064,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } as SessionEvent); yield* waitForSdkEventQueue(); - assert.equal( + NodeAssert.equal( runtimeEvents.some((event) => event.type === "turn.completed"), false, ); @@ -3096,9 +3100,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const completions = runtimeEvents.filter( (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - assert.equal(messageCompleted?.type, "item.completed"); - assert.equal(String(messageCompleted?.turnId), String(turn.turnId)); - assert.equal(completions.length, 1); + NodeAssert.equal(messageCompleted?.type, "item.completed"); + NodeAssert.equal(String(messageCompleted?.turnId), String(turn.turnId)); + NodeAssert.equal(completions.length, 1); yield* adapter.stopSession(threadId); }), @@ -3124,7 +3128,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; config.onEvent({ @@ -3145,8 +3149,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const runningState = runtimeEvents.find( (event) => event.type === "session.state.changed" && event.payload.state === "running", ); - assert.equal(runtimeWarning, undefined); - assert.equal(runningState, undefined); + NodeAssert.equal(runtimeWarning, undefined); + NodeAssert.equal(runningState, undefined); yield* adapter.stopSession(threadId); }), @@ -3172,7 +3176,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3252,8 +3256,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { event.payload.message.includes("sdk-turn-unmapped-after-complete-second"), ); - assert.equal(runtimeWarning, undefined); - assert.equal(staleRunningState, undefined); + NodeAssert.equal(runtimeWarning, undefined); + NodeAssert.equal(staleRunningState, undefined); yield* adapter.stopSession(threadId); }), @@ -3279,7 +3283,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3311,7 +3315,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ) { yield* waitForSdkEventQueue(); } - assert.equal( + NodeAssert.equal( runtimeEvents.some( (event) => event.type === "session.state.changed" && @@ -3361,21 +3365,21 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const completionsAfterFirstIdle = runtimeEvents.filter( (event) => event.type === "turn.completed", ); - assert.equal( + NodeAssert.equal( completionsAfterFirstIdle.filter( (event) => String(event.turnId) === String(firstTurn.turnId), ).length, 1, ); - assert.equal( + NodeAssert.equal( completionsAfterFirstIdle.filter( (event) => String(event.turnId) === String(secondTurn.turnId), ).length, 0, ); const latestEventAfterFirstIdle = runtimeEvents.at(-1); - assert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); - assert.equal(latestEventAfterFirstIdle.payload.state, "running"); + NodeAssert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); + NodeAssert.equal(latestEventAfterFirstIdle.payload.state, "running"); emit({ id: "evt-copilot-queued-second-turn-start", @@ -3422,7 +3426,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), ); - assert.equal(secondCompletions.length, 1); + NodeAssert.equal(secondCompletions.length, 1); yield* adapter.stopSession(threadId); }), @@ -3448,7 +3452,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3548,7 +3552,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "runtime.warning" && event.payload.message.includes("sdk-turn-stale"), ); - assert.equal(staleWarning, undefined); + NodeAssert.equal(staleWarning, undefined); const firstCompletion = runtimeEvents.find( (event) => @@ -3558,8 +3562,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), ); - assert.equal(firstCompletion?.type, "turn.completed"); - assert.equal(secondCompletion?.type, "turn.completed"); + NodeAssert.equal(firstCompletion?.type, "turn.completed"); + NodeAssert.equal(secondCompletion?.type, "turn.completed"); yield* adapter.stopSession(threadId); }), @@ -3585,7 +3589,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const replayTimestamp = "1900-01-01T00:00:00.000Z"; @@ -3656,8 +3660,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const completions = runtimeEvents.filter( (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - assert.equal(replayWarnings.length, 0); - assert.equal(completions.length, 1); + NodeAssert.equal(replayWarnings.length, 0); + NodeAssert.equal(completions.length, 1); yield* adapter.stopSession(threadId); }), @@ -3685,7 +3689,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3780,8 +3784,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { (event) => event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), ); - assert.equal(firstTurnCompletions.length, 1); - assert.equal(secondTurnCompletions.length, 1); + NodeAssert.equal(firstTurnCompletions.length, 1); + NodeAssert.equal(secondTurnCompletions.length, 1); yield* adapter.stopSession(threadId); }), @@ -3818,7 +3822,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); const config = runtimeMock.state.createSessionConfigs.at(-1); - assert.ok(config?.onEvent); + NodeAssert.ok(config?.onEvent); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; @@ -3863,16 +3867,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(runtimeMock.state.nativeWriteCalls > 0, true); - assert.equal(disconnectsBeforeDrain, 0); - assert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); - assert.equal(runtimeMock.state.stopCalls, 1); + 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"); - assert.equal(completed?.type, "turn.completed"); + NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { - assert.equal(String(completed.turnId), String(turn.turnId)); - assert.equal(completed.payload.state, "completed"); + NodeAssert.equal(String(completed.turnId), String(turn.turnId)); + NodeAssert.equal(completed.payload.state, "completed"); } }), ); @@ -3908,16 +3912,16 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); const aborted = runtimeEvents.find((event) => event.type === "turn.aborted"); const completed = runtimeEvents.find((event) => event.type === "turn.completed"); - assert.equal(aborted?.type, "turn.aborted"); - assert.equal(completed?.type, "turn.completed"); + NodeAssert.equal(aborted?.type, "turn.aborted"); + NodeAssert.equal(completed?.type, "turn.completed"); if (aborted?.type === "turn.aborted" && completed?.type === "turn.completed") { - assert.equal(String(completed.turnId), String(aborted.turnId)); - assert.equal(completed.payload.state, "failed"); - assert.equal(completed.payload.errorMessage, "Copilot send rejected"); + 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 index 6b56cda8f32..1a355e79078 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import type { CopilotClient, @@ -307,7 +307,7 @@ function createBaseEvent(input: { }) { const providerRefs = providerRefsFromSdkEvent(input.raw, input.requestId); return { - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: PROVIDER, threadId: input.threadId, createdAt: input.createdAt ?? nowIso(), @@ -3003,7 +3003,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); } - const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); + const turnId = TurnId.make(`copilot-turn-${NodeCrypto.randomUUID()}`); const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const rawReasoningEffort = getModelSelectionStringOptionValue( diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 99993740470..22c153814d3 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import { beforeEach, describe, it } from "@effect/vitest"; import { CopilotSettings } from "@t3tools/contracts"; @@ -74,9 +74,9 @@ describe("CopilotProvider status", () => { cwd: process.cwd(), }); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal(snapshot.message, "401 Unauthorized"); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.message, "401 Unauthorized"); }), ); @@ -94,9 +94,9 @@ describe("CopilotProvider status", () => { cwd: process.cwd(), }); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, false); - assert.equal( + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal( snapshot.message, "The configured Copilot binary could not be started: /missing/copilot.", ); @@ -118,8 +118,8 @@ describe("CopilotProvider status", () => { vi.setSystemTime(DateTime.makeUnsafe("2026-06-08T12:01:00.000Z").epochMilliseconds); const secondSnapshot = yield* statusCheck; - assert.equal(firstSnapshot.checkedAt, "2026-06-08T12:00:00.000Z"); - assert.equal(secondSnapshot.checkedAt, "2026-06-08T12:01:00.000Z"); + 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/CopilotToolClassification.test.ts b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts index fb190b8c95a..a60d30e327d 100644 --- a/apps/server/src/provider/Layers/CopilotToolClassification.test.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import { describe, it } from "@effect/vitest"; @@ -9,12 +9,12 @@ import { describe("CopilotToolClassification", () => { it("classifies Copilot tool lifecycle items consistently", () => { - assert.equal(classifyCopilotToolItemType({ toolName: "bash" }), "command_execution"); - assert.equal( + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "bash" }), "command_execution"); + NodeAssert.equal( classifyCopilotToolItemType({ toolName: "Task_complete" }), "collab_agent_tool_call", ); - assert.equal( + NodeAssert.equal( classifyCopilotToolItemType({ toolName: "update", arguments: { @@ -24,7 +24,7 @@ describe("CopilotToolClassification", () => { }), "file_change", ); - assert.equal( + NodeAssert.equal( classifyCopilotToolItemType({ toolName: "run_in_terminal", arguments: { @@ -33,7 +33,7 @@ describe("CopilotToolClassification", () => { }), "file_change", ); - assert.equal( + NodeAssert.equal( classifyCopilotToolItemType({ toolName: "execute", arguments: { @@ -43,20 +43,20 @@ describe("CopilotToolClassification", () => { }), "file_change", ); - assert.equal(classifyCopilotToolItemType({ toolName: "Read" }), "dynamic_tool_call"); - assert.equal(classifyCopilotToolItemType({ toolName: "web_fetch" }), "web_search"); - assert.equal(classifyCopilotToolItemType({ toolName: "screenshot" }), "image_view"); - assert.equal( + 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", ); }); it("detects read-only Copilot tools", () => { - assert.equal(isReadOnlyCopilotToolName("Read"), true); - assert.equal(isReadOnlyCopilotToolName("read_file"), true); - assert.equal(isReadOnlyCopilotToolName("grep"), true); - assert.equal(isReadOnlyCopilotToolName("edit_file"), false); - assert.equal(isReadOnlyCopilotToolName("bash"), false); + 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/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 03e31366ad9..5bc7df270eb 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it } from "@effect/vitest"; @@ -14,7 +14,7 @@ import { } from "./copilotRuntime.ts"; function assertStdioConnection(connection: CopilotClientOptions["connection"]) { - assert.equal(connection?.kind, "stdio"); + NodeAssert.equal(connection?.kind, "stdio"); return connection; } @@ -24,7 +24,7 @@ describe("buildCopilotClientOptions", () => { it("leaves POSIX PATH hydration to the shared server environment setup", () => { const env = normalizeCopilotRuntimeEnvironment({ PATH: "/custom/bin:/bin" }, "darwin"); - assert.equal(env.PATH, "/custom/bin:/bin"); + NodeAssert.equal(env.PATH, "/custom/bin:/bin"); }); describe("capabilitiesFromCopilotModel", () => { @@ -44,7 +44,7 @@ describe("buildCopilotClientOptions", () => { defaultReasoningEffort: "medium", }); - assert.deepStrictEqual(capabilities.optionDescriptors, [ + NodeAssert.deepStrictEqual(capabilities.optionDescriptors, [ { id: "reasoningEffort", label: "Reasoning", @@ -84,14 +84,14 @@ describe("buildCopilotClientOptions", () => { }, }); - assert.deepStrictEqual(capabilities.optionDescriptors, []); + NodeAssert.deepStrictEqual(capabilities.optionDescriptors, []); }); }); it("hydrates a missing POSIX SHELL for Copilot shell spawning", () => { const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); - assert.ok(POSIX_SHELL_FALLBACKS.some((shell) => shell === env.SHELL)); + NodeAssert.ok(POSIX_SHELL_FALLBACKS.some((shell) => shell === env.SHELL)); }); it("replaces POSIX SHELL values that the Copilot CLI rejects", () => { @@ -102,24 +102,24 @@ describe("buildCopilotClientOptions", () => { "darwin", ); - assert.equal(relativeShellEnv.SHELL, fallbackShell); - assert.equal(shellWithWhitespaceEnv.SHELL, fallbackShell); + NodeAssert.equal(relativeShellEnv.SHELL, fallbackShell); + NodeAssert.equal(shellWithWhitespaceEnv.SHELL, fallbackShell); }); it("preserves valid POSIX SHELL paths", () => { const validShell = normalizeCopilotRuntimeEnvironment({}, "darwin").SHELL; - assert.ok(validShell); + NodeAssert.ok(validShell); const env = normalizeCopilotRuntimeEnvironment({ SHELL: validShell }, "darwin"); - assert.equal(env.SHELL, validShell); + NodeAssert.equal(env.SHELL, validShell); }); it("forces the Copilot POSIX shell spawn backend to avoid node-pty failures", () => { const env = normalizeCopilotRuntimeEnvironment({}, "darwin"); - assert.equal(env.COPILOT_FEATURE_FLAGS, "SHELL_SPAWN_BACKEND"); - assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, "true"); + 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", () => { @@ -128,15 +128,15 @@ describe("buildCopilotClientOptions", () => { "darwin", ); - assert.equal(env.COPILOT_FEATURE_FLAGS, "FOCUSED_TOOLS,SHELL_SPAWN_BACKEND,MCP_APPS"); + 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"); - assert.equal(env.SHELL, "bash"); - assert.equal(env.COPILOT_FEATURE_FLAGS, undefined); - assert.equal(env.COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND, undefined); + 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) => { @@ -163,14 +163,14 @@ describe("buildCopilotClientOptions", () => { }); const connection = assertStdioConnection(options.connection); - assert.ok(connection.path?.includes("node_modules/.bin/copilot")); - assert.equal(options.workingDirectory, "/tmp/project"); - assert.equal(options.baseDirectory, "/tmp/t3-copilot-home"); - assert.equal(options.logLevel, "error"); - assert.equal(options.mode, "copilot-cli"); - assert.equal(options.env?.COPILOT_CLI_PATH, undefined); - assert.equal(options.env?.GITHUB_TOKEN, "github-token"); - assert.equal(options.env?.PATH, "/usr/bin"); + 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"); }), ); @@ -182,7 +182,7 @@ describe("buildCopilotClientOptions", () => { platform: "darwin", }); - assert.ok(cliPath?.includes("node_modules/.bin/copilot")); + NodeAssert.ok(cliPath?.includes("node_modules/.bin/copilot")); }), ); @@ -204,8 +204,8 @@ describe("buildCopilotClientOptions", () => { }); const connection = assertStdioConnection(options.connection); - assert.equal(connection.path, configuredBinaryPath); - assert.equal(options.env?.COPILOT_CLI_PATH, undefined); + NodeAssert.equal(connection.path, configuredBinaryPath); + NodeAssert.equal(options.env?.COPILOT_CLI_PATH, undefined); }), ); }); @@ -219,9 +219,9 @@ describe("buildCopilotClientOptions", () => { login: "octocat", }); - assert.equal(snapshot.auth.status, "authenticated"); - assert.equal(snapshot.auth.type, "user"); - assert.equal(snapshot.auth.label, "@octocat - github.com"); + 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", () => { @@ -233,8 +233,8 @@ describe("buildCopilotClientOptions", () => { login: "zortos293", }); - assert.equal(snapshot.auth.status, "authenticated"); - assert.equal(snapshot.auth.type, "gh-cli"); - assert.equal(snapshot.auth.label, "zortos293 (via gh)"); + 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 index 85ddeafaa1c..d763e5dbf1b 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -8,9 +8,9 @@ import { type ModelInfo, } from "@github/copilot-sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { accessSync, constants as fsConstants, statSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import type { CopilotSettings, ModelCapabilities, @@ -135,11 +135,11 @@ export const createCopilotClient = Effect.fn("createCopilotClient")(function* (i function isExecutableFile(path: string): boolean { try { - if (!statSync(path).isFile()) { + if (!NodeFS.statSync(path).isFile()) { return false; } - accessSync(path, fsConstants.X_OK); + NodeFS.accessSync(path, NodeFS.constants.X_OK); return true; } catch { return false; @@ -147,7 +147,7 @@ function isExecutableFile(path: string): boolean { } function isValidPosixShellPath(path: string | undefined): path is string { - return !!path && !/\s/.test(path) && isAbsolute(path) && isExecutableFile(path); + return !!path && !/\s/.test(path) && NodePath.isAbsolute(path) && isExecutableFile(path); } function resolvePosixShellPath(currentShell: string | undefined): string | undefined { @@ -237,7 +237,7 @@ function candidateDirectoryAncestors(directory: string): ReadonlyArray { for (let depth = 0; depth < 8; depth += 1) { directories.push(current); - const parent = dirname(current); + const parent = NodePath.dirname(current); if (parent === current) { break; } @@ -253,7 +253,7 @@ export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliP readonly env?: Record; readonly platform: NodeJS.Platform; }): Effect.fn.Return { - const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const moduleDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const candidateRoots = new Set(); if (input.cwd) { @@ -266,7 +266,7 @@ export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliP } for (const root of candidateRoots) { - const candidate = join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); + const candidate = NodePath.join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); const resolved = yield* resolveCopilotCommandPath(candidate, { env: input.env ?? process.env, platform: input.platform, diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 44d7f45ec10..8cfb1c3ae9d 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -179,7 +179,6 @@ describe("getComposerProviderState", () => { { id: "long_context", label: "Long Context" }, ]), ]), - prompt: "", modelOptions: selections(["variant", "test"], ["contextTier", "long_context"]), }); From dd399a9a0a12df68af40b4b15d14a248fc367ac7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:23:23 +0200 Subject: [PATCH 087/104] fix: match mobile title seed truncation --- .../Layers/ProviderCommandReactor.test.ts | 57 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 3 +- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 63806798d93..f72e5089aa8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -831,6 +831,59 @@ describe("ProviderCommandReactor", () => { 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("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2184,7 +2237,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -2240,7 +2293,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"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 65b95257bf3..ba8f84cba64 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -116,7 +116,8 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea } return ( - trimmedCurrentTitle === trimmedTitleSeed || truncate(trimmedCurrentTitle) === trimmedTitleSeed + trimmedCurrentTitle === trimmedTitleSeed || + truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed ); } From c548a597a1977172d4d77ad42bf49a0c9f67b011 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:49:59 +0200 Subject: [PATCH 088/104] fix: address copilot review feedback --- .../Layers/ProviderCommandReactor.test.ts | 40 +- .../Layers/ProviderCommandReactor.ts | 5 +- .../Layers/ProviderRuntimeIngestion.test.ts | 46 +++ .../Layers/ProviderRuntimeIngestion.ts | 12 +- .../src/provider/Drivers/CopilotDriver.ts | 3 + .../provider/Layers/CopilotAdapter.test.ts | 356 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 46 ++- .../Layers/CopilotToolClassification.test.ts | 1 + .../Layers/CopilotToolClassification.ts | 3 +- .../src/provider/copilotRuntime.test.ts | 51 +++ apps/server/src/provider/copilotRuntime.ts | 44 ++- .../textGeneration/CopilotTextGeneration.ts | 68 ++-- 12 files changed, 609 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f72e5089aa8..b104afccb88 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -884,6 +884,42 @@ describe("ProviderCommandReactor", () => { 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"; @@ -2198,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"), @@ -2216,7 +2252,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ba8f84cba64..5deaba24d33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -115,9 +115,12 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea return false; } + const currentLooksUserEditedFromTruncatedSeed = + trimmedTitleSeed.endsWith("...") && trimmedCurrentTitle.includes("..."); return ( trimmedCurrentTitle === trimmedTitleSeed || - truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed + (!currentLooksUserEditedFromTruncatedSeed && + truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed) ); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index cb508afecda..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, }; } @@ -457,6 +462,47 @@ describe("ProviderRuntimeIngestion", () => { 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"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b098fcb3f95..5b65ccbb4fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1295,10 +1295,15 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; const lifecycleEventTurnId = eventTurnId; - const providerActiveTurnId = + const providerSessionForUnscopedCompletion = event.type === "turn.completed" && eventTurnId === undefined - ? yield* getExpectedProviderTurnIdForThread(thread.id) + ? yield* getProviderSessionForThread(thread.id) : undefined; + const providerActiveTurnId = providerSessionForUnscopedCompletion?.activeTurnId; + const unscopedCompletionHasNoProviderSession = + event.type === "turn.completed" && + eventTurnId === undefined && + providerSessionForUnscopedCompletion === undefined; const conflictsWithActiveTurn = activeTurnId !== null && @@ -1339,7 +1344,8 @@ const make = Effect.gen(function* () { } if (activeTurnId !== null && lifecycleEventTurnId === undefined) { return ( - providerActiveTurnId === undefined || sameId(activeTurnId, providerActiveTurnId) + !unscopedCompletionHasNoProviderSession && + (providerActiveTurnId === undefined || sameId(activeTurnId, providerActiveTurnId)) ); } // Only the active turn may close the lifecycle state. diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index 05bbd60ac77..01a322e3914 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -12,6 +12,8 @@ * @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 { Duration, Effect, Path, Schema, Stream } from "effect"; import * as FileSystem from "effect/FileSystem"; @@ -41,6 +43,7 @@ const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); export type CopilotDriverEnv = | FileSystem.FileSystem | Path.Path + | Context.Service.Identifier | ProviderEventLoggers | ServerConfig; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index b329fe4d1cc..50058f6b657 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -376,6 +376,60 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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; @@ -592,6 +646,60 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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; @@ -1681,6 +1789,85 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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", () => @@ -2017,6 +2204,99 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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; @@ -3881,6 +4161,82 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 1a355e79078..406c7ba699c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -11,6 +11,7 @@ import type { SessionEvent, } from "@github/copilot-sdk"; import { + type CanonicalRequestType, EventId, type CopilotSettings, type ProviderApprovalDecision, @@ -136,10 +137,7 @@ interface PendingUserInputHandler { interface PendingPermissionBinding { readonly requestId: string; - readonly requestType: - | "command_execution_approval" - | "file_read_approval" - | "file_change_approval"; + readonly requestType: CanonicalRequestType; readonly turnId?: TurnId | undefined; readonly permissionRequest: SessionPermissionRequest; readonly promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined; @@ -435,16 +433,16 @@ function permissionAutoApprovedByRuntimeMode( } } -function mapPermissionRequestType( - request: SessionPermissionRequest, -): "command_execution_approval" | "file_read_approval" | "file_change_approval" { +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 "command_execution_approval"; + return "dynamic_tool_call"; } } @@ -514,7 +512,7 @@ function sessionApprovalDecisionFromPermissionRequest( case "write": return request.canOfferSessionApproval ? approve({ kind: "write" }) : undefined; case "read": - return approve({ kind: "read" }); + return { kind: "approve-for-session" }; case "mcp": return approve({ kind: "mcp", serverName: request.serverName, toolName: request.toolName }); case "url": { @@ -552,7 +550,9 @@ function isPermissionCompletionApproved(result: PermissionRequestResult): boolea kind === "approved" || kind.startsWith("approved-") || kind === "approve-once" || - kind === "approve-for-session" + kind === "approve-for-session" || + kind === "approve-for-location" || + kind === "approve-permanently" ); } @@ -687,9 +687,6 @@ function completedToolDiffText( detail: string | undefined, ): string | undefined { const normalized = trimOrUndefined(detail); - if (!normalized) { - return undefined; - } const applyPatchDiff = extractCopilotApplyPatchEdit(normalized) ?? extractCopilotApplyPatchEdit(toolMeta?.command); if (isApplyPatchTool(toolMeta?.toolName)) { @@ -698,6 +695,9 @@ function completedToolDiffText( if (toolMeta?.itemType === "file_change" && applyPatchDiff) { return applyPatchDiff; } + if (!normalized) { + return undefined; + } if (toolMeta?.itemType !== "command_execution" && toolMeta?.itemType !== "file_change") { return undefined; } @@ -1030,8 +1030,18 @@ function answerFromUserInput( (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(preferredAnswer.trim()); + normalizedChoices.size === 0 ? true : !normalizedChoices.has(preferredAnswerTrimmed); return { answer: preferredAnswer, wasFreeform, @@ -1048,6 +1058,7 @@ function answersFromCompletedUserInput( function settlePendingPermissionHandlers( context: CopilotSessionContext, + onBindingSettled?: (binding: PendingPermissionBinding) => Effect.Effect, ): Effect.Effect { return Effect.gen(function* () { for (const handlers of context.pendingPermissionHandlersBySignature.values()) { @@ -1060,6 +1071,9 @@ function settlePendingPermissionHandlers( 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(); }); @@ -3228,7 +3242,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.stopped = true; yield* Effect.promise(() => context.eventChain); - yield* settlePendingPermissionHandlers(context); + 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); diff --git a/apps/server/src/provider/Layers/CopilotToolClassification.test.ts b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts index a60d30e327d..697358857f5 100644 --- a/apps/server/src/provider/Layers/CopilotToolClassification.test.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.test.ts @@ -50,6 +50,7 @@ describe("CopilotToolClassification", () => { classifyCopilotToolItemType({ toolName: "call_tool", mcpServerName: "github" }), "mcp_tool_call", ); + NodeAssert.equal(classifyCopilotToolItemType({ toolName: "TodoWrite" }), "dynamic_tool_call"); }); it("detects read-only Copilot tools", () => { diff --git a/apps/server/src/provider/Layers/CopilotToolClassification.ts b/apps/server/src/provider/Layers/CopilotToolClassification.ts index 5d2d1d18e4b..1a41d5e6733 100644 --- a/apps/server/src/provider/Layers/CopilotToolClassification.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.ts @@ -73,8 +73,9 @@ export function isReadOnlyCopilotToolName(toolName: string): boolean { function toolNameImpliesFileChange(toolName: string, arguments_: unknown): boolean { const normalized = toolName.toLowerCase(); + const isPlanningWriteTool = normalized.includes("todo") || normalized.includes("plan"); if ( - normalized.includes("write") || + (normalized.includes("write") && !isPlanningWriteTool) || normalized.includes("edit") || normalized.includes("patch") || normalized.includes("replace") diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 5bc7df270eb..074450c90bd 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -3,12 +3,16 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it } from "@effect/vitest"; import type { CopilotClientOptions } from "@github/copilot-sdk"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import { authSnapshotFromCopilotSdk, buildCopilotClientOptions, capabilitiesFromCopilotModel, + createCopilotClient, + modelsFromCopilotSdk, normalizeCopilotRuntimeEnvironment, resolveBundledCopilotCliPath, } from "./copilotRuntime.ts"; @@ -27,6 +31,53 @@ describe("buildCopilotClientOptions", () => { 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); + NodeAssert.equal( + (failure as { readonly _tag?: string })._tag, + "CopilotCliPathResolutionError", + ); + } + }), + ); + + it("normalizes built-in Copilot SDK model slugs", () => { + const [model] = 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], + ], + customModels: ["gpt-4.1"], + }); + + 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({ diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index d763e5dbf1b..8160b9b4867 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -21,7 +21,7 @@ import type { } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -68,6 +68,12 @@ class CopilotCliPathResolutionError extends Data.TaggedError("CopilotCliPathReso readonly message: string; }> {} +function copilotClientConfigurationError(cause: unknown): CopilotCliPathResolutionError { + return new CopilotCliPathResolutionError({ + message: cause instanceof Error ? cause.message : String(cause), + }); +} + export function trimOrUndefined(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -130,7 +136,11 @@ export const createCopilotClient = Effect.fn("createCopilotClient")(function* (i readonly logLevel?: CopilotClientOptions["logLevel"]; readonly onListModels?: CopilotClientOptions["onListModels"]; }): Effect.fn.Return { - return new CopilotClient(yield* buildCopilotClientOptions(input)); + const options = yield* buildCopilotClientOptions(input); + return yield* Effect.try({ + try: () => new CopilotClient(options), + catch: copilotClientConfigurationError, + }); }); function isExecutableFile(path: string): boolean { @@ -313,13 +323,17 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( }) : undefined; const cliPath = configuredCliPath ?? bundledCliPath; + const connection = cliUrl + ? yield* Effect.try({ + try: () => RuntimeConnection.forUri(cliUrl), + catch: copilotClientConfigurationError, + }) + : cliPath + ? RuntimeConnection.forStdio({ path: cliPath }) + : undefined; return { - ...(cliUrl - ? { connection: RuntimeConnection.forUri(cliUrl) } - : cliPath - ? { connection: RuntimeConnection.forStdio({ path: cliPath }) } - : {}), + ...(connection ? { connection } : {}), mode: "copilot-cli", ...(input.cwd ? { workingDirectory: input.cwd } : {}), ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), @@ -440,12 +454,16 @@ export function modelsFromCopilotSdk(input: { readonly models: ReadonlyArray; readonly customModels: ReadonlyArray; }): ReadonlyArray { - const builtInModels = input.models.map((model) => ({ - slug: model.id.trim(), - name: trimOrUndefined(model.name) ?? model.id.trim(), - isCustom: false, - capabilities: capabilitiesFromCopilotModel(model), - })) satisfies ReadonlyArray; + const builtInModels = input.models.map((model) => { + const rawSlug = model.id.trim(); + const slug = normalizeModelSlug(rawSlug, PROVIDER) ?? rawSlug; + return { + slug, + name: trimOrUndefined(model.name) ?? slug, + isCustom: false, + capabilities: capabilitiesFromCopilotModel(model), + }; + }) satisfies ReadonlyArray; return providerModelsFromSettings( builtInModels, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 9614da7c014..4b106d543fd 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -183,38 +183,44 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( return existing.client; } - const client = yield* createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), - env: environment, - platform, - logLevel: "error", - }).pipe( - Effect.mapError((cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to configure Copilot client."), - cause, - ), - ), + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const client = yield* restore( + createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), + ), + ), + ); + yield* Effect.tryPromise({ + try: () => client.start(), + catch: (cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }); + + sharedClients.set(clientKey, { + client, + activeRequests: 1, + idleCloseFiber: null, + }); + return client; + }), ); - yield* Effect.tryPromise({ - try: () => client.start(), - catch: (cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to start Copilot client."), - cause, - ), - }); - - sharedClients.set(clientKey, { - client, - activeRequests: 1, - idleCloseFiber: null, - }); - return client; }), ); From 55ecc6bc209c3f52f95420bc2882a8f56b5c0e66 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 17:18:47 +0200 Subject: [PATCH 089/104] fix: address copilot follow-up review --- .../src/provider/copilotRuntime.test.ts | 17 ++- apps/server/src/provider/copilotRuntime.ts | 15 ++- .../textGeneration/CopilotTextGeneration.ts | 105 +++++++++++------- 3 files changed, 93 insertions(+), 44 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 074450c90bd..0053e4deb9e 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -56,8 +56,8 @@ describe("buildCopilotClientOptions", () => { }), ); - it("normalizes built-in Copilot SDK model slugs", () => { - const [model] = modelsFromCopilotSdk({ + it("normalizes and deduplicates built-in Copilot SDK model slugs", () => { + const models = modelsFromCopilotSdk({ models: [ { id: "4.1", @@ -70,10 +70,23 @@ describe("buildCopilotClientOptions", () => { 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); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 8160b9b4867..ea12d3fe0b7 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -454,16 +454,23 @@ export function modelsFromCopilotSdk(input: { readonly models: ReadonlyArray; readonly customModels: ReadonlyArray; }): ReadonlyArray { - const builtInModels = input.models.map((model) => { + const builtInModels: ServerProviderModel[] = []; + const seenBuiltInSlugs = new Set(); + + for (const model of input.models) { const rawSlug = model.id.trim(); const slug = normalizeModelSlug(rawSlug, PROVIDER) ?? rawSlug; - return { + if (seenBuiltInSlugs.has(slug)) { + continue; + } + seenBuiltInSlugs.add(slug); + builtInModels.push({ slug, name: trimOrUndefined(model.name) ?? slug, isCustom: false, capabilities: capabilitiesFromCopilotModel(model), - }; - }) satisfies ReadonlyArray; + }); + } return providerModelsFromSettings( builtInModels, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 4b106d543fd..bf690f2ede5 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -174,7 +174,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( cwd: input.cwd, }); - const client = yield* sharedClientMutex.withPermit( + const existingClient = yield* sharedClientMutex.withPermit( Effect.gen(function* () { const existing = sharedClients.get(clientKey); if (existing) { @@ -182,48 +182,77 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( existing.activeRequests += 1; return existing.client; } + return undefined; + }), + ); + if (existingClient) { + return { clientKey, client: existingClient }; + } - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const client = yield* restore( - createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), - env: environment, - platform, - logLevel: "error", - }).pipe( - Effect.mapError((cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to configure Copilot client."), - cause, - ), - ), - ), - ); - yield* Effect.tryPromise({ - try: () => client.start(), - catch: (cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to start Copilot client."), - cause, - ), + const newClient = yield* createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError((cause) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to configure Copilot client."), + cause, + ), + ), + ); + yield* Effect.tryPromise({ + try: (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) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }); - sharedClients.set(clientKey, { - client, - activeRequests: 1, - idleCloseFiber: null, - }); - return client; - }), - ); + const client = 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 }; }); From aff2e7c93d0969b34182bc87fedb59f7d01af1c3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 17:32:03 +0200 Subject: [PATCH 090/104] fix: address copilot review follow-ups --- .../src/provider/Drivers/CopilotDriver.ts | 6 +- .../src/provider/Layers/CopilotAdapter.ts | 8 +- .../src/provider/Layers/CopilotProvider.ts | 3 +- .../src/textGeneration/CodexTextGeneration.ts | 161 ++++++------ .../textGeneration/CopilotTextGeneration.ts | 248 +++++++++--------- 5 files changed, 220 insertions(+), 206 deletions(-) diff --git a/apps/server/src/provider/Drivers/CopilotDriver.ts b/apps/server/src/provider/Drivers/CopilotDriver.ts index 01a322e3914..a98a68ab370 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -14,7 +14,11 @@ import { CopilotSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import type * as Context from "effect/Context"; -import { Duration, Effect, Path, Schema, Stream } from "effect"; +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"; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 406c7ba699c..dd4d3376b72 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -33,7 +33,13 @@ import { } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { DateTime, Deferred, Effect, Path, Predicate, PubSub, Stream } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +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"; diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 050925a9eaa..753b8a1b5a5 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -1,6 +1,7 @@ import { ProviderDriverKind, type CopilotSettings } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { DateTime, Effect } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index abb1c57b6f7..1f99ec75ef7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -15,7 +15,8 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { type BranchNameGenerationInput, type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { type BranchNameGenerationInput } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -296,104 +297,100 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CodexTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - const generated = yield* runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - return makeCommitMessageGenerationResult({ - generated, - includeBranch: input.includeBranch === true, - sanitizeBranch: sanitizeFeatureBranchName, + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: sanitizeFeatureBranchName, + }); }); - }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CodexTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CodexTextGeneration.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* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return makePrContentGenerationResult(generated); }); - return makePrContentGenerationResult(generated); - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CodexTextGeneration.generateBranchName", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return makeBranchNameGenerationResult(generated, sanitizeBranchFragment); }); - return makeBranchNameGenerationResult(generated, sanitizeBranchFragment); - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CodexTextGeneration.generateThreadTitle", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return makeThreadTitleGenerationResult(generated); }); - - return makeThreadTitleGenerationResult(generated); - }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index bf690f2ede5..ebba0167143 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -1,5 +1,9 @@ import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; -import { Effect, Exit, Fiber, Schema, Scope } from "effect"; +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 { @@ -22,7 +26,7 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeBranchNameGenerationResult, makeCommitMessageGenerationResult, @@ -205,52 +209,58 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ), ), ); - yield* 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) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to start Copilot client."), - cause, - ), - }); - - const client = yield* sharedClientMutex.withPermit( + const client = yield* Effect.uninterruptibleMask((restore) => 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; - } + 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) => + copilotTextGenerationError( + input.operation, + detailFromCause(cause, "Failed to start Copilot client."), + cause, + ), + }), + ); - sharedClients.set(clientKey, { - client: newClient, - activeRequests: 1, - idleCloseFiber: null, - }); - return newClient; + 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 }; @@ -405,95 +415,91 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ), ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CopilotTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - const generated = yield* runCopilotJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); + 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)), + return makeCommitMessageGenerationResult({ + generated, + includeBranch: input.includeBranch === true, + sanitizeBranch: (branch) => sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + }); }); - }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CopilotTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - const generated = yield* runCopilotJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + 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); }); - 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, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CopilotTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runCopilotJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, + return makeBranchNameGenerationResult(generated, (branch) => + sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), + ); }); - return makeBranchNameGenerationResult(generated, (branch) => - sanitizeFeatureBranchName(sanitizeBranchFragment(branch)), - ); - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CopilotTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runCopilotJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CopilotTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, + return makeThreadTitleGenerationResult(generated); }); - const generated = yield* runCopilotJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return makeThreadTitleGenerationResult(generated); - }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); From 9842624ad8b3d6c15900fb4a638fcaf5521f6c61 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 21:45:49 +0200 Subject: [PATCH 091/104] fix: structure copilot cli resolution errors --- .../src/provider/copilotRuntime.test.ts | 15 ++++- apps/server/src/provider/copilotRuntime.ts | 56 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 0053e4deb9e..d1c458beccf 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -48,9 +48,20 @@ describe("buildCopilotClientOptions", () => { 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( - (failure as { readonly _tag?: string })._tag, - "CopilotCliPathResolutionError", + error.message, + "Copilot CLI path resolution failed (serverUrl=http://[::1): Failed to construct Copilot client.", ); } }), diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index ea12d3fe0b7..bd53a87052c 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -23,8 +23,8 @@ 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 Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; @@ -64,13 +64,40 @@ export class CopilotProbePromiseError extends Error { } } -class CopilotCliPathResolutionError extends Data.TaggedError("CopilotCliPathResolutionError")<{ - readonly message: string; -}> {} +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(cause: unknown): CopilotCliPathResolutionError { +function copilotClientConfigurationError(input: { + readonly settings: CopilotSettings; + readonly detail: string; + readonly cause: unknown; +}): CopilotCliPathResolutionError { return new CopilotCliPathResolutionError({ - message: cause instanceof Error ? cause.message : String(cause), + detail: input.detail, + ...(trimOrUndefined(input.settings.binaryPath) + ? { binaryPath: trimOrUndefined(input.settings.binaryPath) } + : {}), + ...(trimOrUndefined(input.settings.serverUrl) + ? { serverUrl: trimOrUndefined(input.settings.serverUrl) } + : {}), + cause: input.cause, }); } @@ -139,7 +166,12 @@ export const createCopilotClient = Effect.fn("createCopilotClient")(function* (i const options = yield* buildCopilotClientOptions(input); return yield* Effect.try({ try: () => new CopilotClient(options), - catch: copilotClientConfigurationError, + catch: (cause) => + copilotClientConfigurationError({ + settings: input.settings, + detail: "Failed to construct Copilot client.", + cause, + }), }); }); @@ -233,7 +265,8 @@ const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCli Effect.catchTag("CommandResolutionError", () => Effect.fail( new CopilotCliPathResolutionError({ - message: `The configured Copilot binary could not be found: ${cliPath}.`, + detail: "The configured Copilot binary could not be found.", + binaryPath: cliPath, }), ), ), @@ -326,7 +359,12 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( const connection = cliUrl ? yield* Effect.try({ try: () => RuntimeConnection.forUri(cliUrl), - catch: copilotClientConfigurationError, + catch: (cause) => + copilotClientConfigurationError({ + settings: input.settings, + detail: "Invalid Copilot server URL.", + cause, + }), }) : cliPath ? RuntimeConnection.forStdio({ path: cliPath }) From 57624f21121ab844731a8a5e9e1593947588449f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 22:10:38 +0200 Subject: [PATCH 092/104] fix: address copilot runtime review comments --- .../src/provider/copilotRuntime.test.ts | 59 +++++++++++++++++++ apps/server/src/provider/copilotRuntime.ts | 48 +++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index d1c458beccf..955933cdda8 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -1,4 +1,8 @@ +// @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"; @@ -12,6 +16,7 @@ import { buildCopilotClientOptions, capabilitiesFromCopilotModel, createCopilotClient, + formatCopilotProbeError, modelsFromCopilotSdk, normalizeCopilotRuntimeEnvironment, resolveBundledCopilotCliPath, @@ -67,6 +72,33 @@ describe("buildCopilotClientOptions", () => { }), ); + 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: [ @@ -283,6 +315,33 @@ describe("buildCopilotClientOptions", () => { NodeAssert.equal(options.env?.COPILOT_CLI_PATH, undefined); }), ); + + it.effect("resolves configured relative binary paths from the workspace cwd", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "copilot-cli-cwd-")); + const binDir = NodePath.join(tempDir, "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: tempDir, + env: { PATH: "/usr/bin" }, + platform: "darwin", + }); + + const connection = assertStdioConnection(options.connection); + NodeAssert.equal(connection.path, binaryPath); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); }); it("omits the generic signed-in user prefix from authenticated Copilot labels", () => { diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index bd53a87052c..1df56b45715 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -113,21 +113,39 @@ export function toCopilotProbeError(cause: unknown): CopilotProbePromiseError { function describeCopilotProbeCause(cause: unknown): string { const seen = new Set(); let current: unknown = cause; + const fallbackMessages: string[] = []; - while (current instanceof Error && !seen.has(current)) { + while (typeof current === "object" && current !== null && !seen.has(current)) { seen.add(current); - const message = current.message.trim(); + 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; } - current = current.cause; + 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 ""; + return fallbackMessages[0] ?? ""; } function authTypeLabel(authType: GetAuthStatusResponse["authType"]): string | undefined { @@ -237,16 +255,29 @@ const resolveCopilotCommandPath = ( input: { readonly env: Record; readonly platform: NodeJS.Platform; + readonly cwd?: string | undefined; }, ) => - resolveCommandPath(command, { env: input.env }).pipe( + 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 cwd?: string | undefined; readonly env?: Record; readonly platform: NodeJS.Platform; }): Effect.fn.Return { @@ -261,7 +292,11 @@ const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCli } const env = input.env ?? process.env; - return yield* resolveCopilotCommandPath(cliPath, { env, platform: input.platform }).pipe( + return yield* resolveCopilotCommandPath(cliPath, { + env, + platform: input.platform, + ...(input.cwd ? { cwd: input.cwd } : {}), + }).pipe( Effect.catchTag("CommandResolutionError", () => Effect.fail( new CopilotCliPathResolutionError({ @@ -344,6 +379,7 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( const configuredCliPath = yield* validateConfiguredCopilotCliPath({ settings: input.settings, + ...(input.cwd ? { cwd: input.cwd } : {}), env, platform: input.platform, }); From 43c744644be59a51f28c39f66184ab99de23a583 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 23:23:29 +0200 Subject: [PATCH 093/104] fix: align copilot binary path resolution --- apps/server/src/provider/Layers/CopilotAdapter.ts | 1 + apps/server/src/provider/copilotRuntime.test.ts | 14 +++++++++----- apps/server/src/provider/copilotRuntime.ts | 10 +++++++--- .../src/textGeneration/CopilotTextGeneration.ts | 1 + 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index dd4d3376b72..f45eef6bc74 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2850,6 +2850,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const client = yield* createCopilotClient({ settings, cwd, + binaryPathBaseDirectory: serverConfig.cwd, ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), ...(options?.environment ? { env: options.environment } : {}), platform, diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 955933cdda8..75f71dc5a80 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -316,10 +316,11 @@ describe("buildCopilotClientOptions", () => { }), ); - it.effect("resolves configured relative binary paths from the workspace cwd", () => + it.effect("resolves configured relative binary paths from the binary path base directory", () => Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "copilot-cli-cwd-")); - const binDir = NodePath.join(tempDir, "node_modules", ".bin"); + 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"); @@ -332,14 +333,17 @@ describe("buildCopilotClientOptions", () => { serverUrl: "", customModels: [], }, - cwd: tempDir, + cwd: workspaceDir, + binaryPathBaseDirectory: baseDir, env: { PATH: "/usr/bin" }, platform: "darwin", }); const connection = assertStdioConnection(options.connection); NodeAssert.equal(connection.path, binaryPath); - NodeFS.rmSync(tempDir, { recursive: true, force: true }); + NodeAssert.equal(options.workingDirectory, workspaceDir); + NodeFS.rmSync(baseDir, { recursive: true, force: true }); + NodeFS.rmSync(workspaceDir, { recursive: true, force: true }); }), ); }); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 1df56b45715..f7e686c4166 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -175,6 +175,7 @@ function normalizeAuthLabelPart(value: string | null | undefined): string | unde 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; @@ -277,7 +278,7 @@ function resolveCommandRelativeToCwd( const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCliPath")( function* (input: { readonly settings: CopilotSettings; - readonly cwd?: string | undefined; + readonly binaryPathBaseDirectory?: string | undefined; readonly env?: Record; readonly platform: NodeJS.Platform; }): Effect.fn.Return { @@ -295,7 +296,7 @@ const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCli return yield* resolveCopilotCommandPath(cliPath, { env, platform: input.platform, - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.binaryPathBaseDirectory ? { cwd: input.binaryPathBaseDirectory } : {}), }).pipe( Effect.catchTag("CommandResolutionError", () => Effect.fail( @@ -361,6 +362,7 @@ export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliP 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; @@ -379,7 +381,9 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( const configuredCliPath = yield* validateConfiguredCopilotCliPath({ settings: input.settings, - ...(input.cwd ? { cwd: input.cwd } : {}), + ...((input.binaryPathBaseDirectory ?? input.cwd) + ? { binaryPathBaseDirectory: input.binaryPathBaseDirectory ?? input.cwd } + : {}), env, platform: input.platform, }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index ebba0167143..e0b5476ed37 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -196,6 +196,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( const newClient = yield* createCopilotClient({ settings: input.settings, cwd: input.cwd, + binaryPathBaseDirectory: serverConfig.cwd, ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), env: environment, platform, From 10ba6f23cfc7fa65372fc91224e3a25677d12196 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:15:55 +0200 Subject: [PATCH 094/104] Add Copilot text generation context tier --- .../CopilotTextGeneration.test.ts | 31 ++++++++++++++++++- .../textGeneration/CopilotTextGeneration.ts | 5 +++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 3e6fc1bc41c..8fd706ee2bf 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -18,6 +18,7 @@ const runtimeMock = vi.hoisted(() => { readonly createSession: ReturnType; }; }>, + sessionConfigs: [] as Array, sessions: [] as Array<{ readonly disconnect: ReturnType; readonly sendAndWait: ReturnType; @@ -28,6 +29,7 @@ const runtimeMock = vi.hoisted(() => { state, reset() { state.createdClients = []; + state.sessionConfigs = []; state.sessions = []; }, }; @@ -44,7 +46,8 @@ vi.mock("../provider/copilotRuntime.ts", async () => { (input: { readonly cwd?: string; readonly baseDirectory?: string }) => { const start = vi.fn(async () => undefined); const stop = vi.fn(async () => undefined); - const createSession = vi.fn(async () => { + const createSession = vi.fn(async (config: unknown) => { + runtimeMock.state.sessionConfigs.push(config); const sendAndWait = vi.fn(async () => ({ data: { content: JSON.stringify({ @@ -148,4 +151,30 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { 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", + }, + ]); + }), + ); }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index e0b5476ed37..88494ec60af 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -44,6 +44,7 @@ type CopilotTextGenerationOperation = | "generateBranchName" | "generateThreadTitle"; type CopilotReasoningEffort = NonNullable; +type CopilotContextTier = NonNullable; interface SharedCopilotTextClientState { readonly client: CopilotClient; @@ -325,6 +326,9 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( 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. @@ -342,6 +346,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( clientName: "t3-code-git-text", model: input.modelSelection.model, ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), workingDirectory: input.cwd, streaming: false, availableTools: [], From 142caaf7375d4e07feb0e37f620388b308e0cf6f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:20:26 +0200 Subject: [PATCH 095/104] Skip Copilot first-turn title generation --- .../Layers/ProviderCommandReactor.test.ts | 6 ++--- .../Layers/ProviderCommandReactor.ts | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b104afccb88..a3860fbf7e6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -641,7 +641,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated after retry"); }); - it("generates a Copilot thread title while starting the first Copilot turn", async () => { + it("skips Copilot thread title generation while starting the first Copilot turn", async () => { const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); const harness = await createHarness({ threadModelSelection: copilotSelection, @@ -681,10 +681,10 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); await harness.drain(); - expect(harness.generateThreadTitle).toHaveBeenCalledOnce(); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Generated title"); + expect(thread?.title).toBe(seededTitle); }); it("does not overwrite an existing custom thread title on the first turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5deaba24d33..51d1966a732 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -44,6 +44,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); +const COPILOT_PROVIDER = ProviderDriverKind.make("copilot"); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -124,6 +125,19 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea ); } +function textGenerationUsesCopilot(input: { + readonly modelSelection: ModelSelection; + readonly providerInstances: Record; +}): boolean { + const instanceDriver = input.providerInstances[input.modelSelection.instanceId]?.driver; + const driver = + instanceDriver ?? + (isProviderDriverKind(input.modelSelection.instanceId) + ? input.modelSelection.instanceId + : null); + return driver !== null && Equal.equals(driver, COPILOT_PROVIDER); +} + function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -717,8 +731,16 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const settings = yield* serverSettingsService.getSettings; + const modelSelection = settings.textGenerationModelSelection; + if ( + textGenerationUsesCopilot({ + modelSelection, + providerInstances: settings.providerInstances, + }) + ) { + return; + } const generated = yield* Effect.suspend(() => textGeneration.generateThreadTitle({ cwd: input.cwd, From ce968b9c8e97199b48fb646813ccd72937cecd2b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:33:12 +0200 Subject: [PATCH 096/104] Move Copilot title fallback into provider --- .../Layers/ProviderCommandReactor.test.ts | 6 ++-- .../Layers/ProviderCommandReactor.ts | 26 ++-------------- .../CopilotTextGeneration.test.ts | 17 ++++++++++ .../textGeneration/CopilotTextGeneration.ts | 31 ++++++++----------- 4 files changed, 35 insertions(+), 45 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3860fbf7e6..b104afccb88 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -641,7 +641,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated after retry"); }); - it("skips Copilot thread title generation while starting the first Copilot turn", async () => { + 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, @@ -681,10 +681,10 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); await harness.drain(); - expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + 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(seededTitle); + expect(thread?.title).toBe("Generated title"); }); it("does not overwrite an existing custom thread title on the first turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 51d1966a732..5deaba24d33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -44,7 +44,6 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); -const COPILOT_PROVIDER = ProviderDriverKind.make("copilot"); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -125,19 +124,6 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea ); } -function textGenerationUsesCopilot(input: { - readonly modelSelection: ModelSelection; - readonly providerInstances: Record; -}): boolean { - const instanceDriver = input.providerInstances[input.modelSelection.instanceId]?.driver; - const driver = - instanceDriver ?? - (isProviderDriverKind(input.modelSelection.instanceId) - ? input.modelSelection.instanceId - : null); - return driver !== null && Equal.equals(driver, COPILOT_PROVIDER); -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -731,16 +717,8 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const settings = yield* serverSettingsService.getSettings; - const modelSelection = settings.textGenerationModelSelection; - if ( - textGenerationUsesCopilot({ - modelSelection, - providerInstances: settings.providerInstances, - }) - ) { - return; - } + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; const generated = yield* Effect.suspend(() => textGeneration.generateThreadTitle({ cwd: input.cwd, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 8fd706ee2bf..31d713946b1 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -177,4 +177,21 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { ]); }), ); + + 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 index 88494ec60af..f920f63500b 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -24,7 +24,6 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, - buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { @@ -103,6 +102,16 @@ function copilotTextClientKey(input: { }); } +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, @@ -484,23 +493,9 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ); }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("CopilotTextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runCopilotJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return makeThreadTitleGenerationResult(generated); - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( + input, + ) => Effect.succeed(copilotThreadTitleFallback(input)); return { generateCommitMessage, From 7d6d1b696439629064afd23dbfd5afd3a4ab7142 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 12:06:07 +0200 Subject: [PATCH 097/104] Fix Copilot follow-up turn completion --- .../provider/Layers/CopilotAdapter.test.ts | 280 ++++++++++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 98 ++++++ 2 files changed, 360 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 50058f6b657..57a419e78e3 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -11,6 +11,7 @@ import type { } 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 { @@ -144,7 +145,10 @@ const nativeEventLogger = { const CopilotAdapterTestLayer = Layer.effect( CopilotAdapter, - makeCopilotAdapter(decodeCopilotSettings({}), { nativeEventLogger }), + makeCopilotAdapter(decodeCopilotSettings({}), { + nativeEventLogger, + turnEndIdleFallbackDelayMs: 25, + }), ).pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1026,6 +1030,155 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + 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; @@ -3265,7 +3418,94 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("keeps one T3 turn active across multiple Copilot SDK loops until idle", () => + 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"); @@ -3313,6 +3553,13 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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, @@ -3342,22 +3589,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-multi-loop-second", }, } as SessionEvent); - - yield* waitForSdkEventQueue(); - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "turn.completed"), - false, - ); - - emit({ - id: "evt-copilot-multi-loop-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, - }, - } as SessionEvent); + yield* TestClock.adjust("25 millis"); for ( let attempt = 0; @@ -3370,6 +3602,18 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ) { 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( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index f45eef6bc74..363bb5f8b64 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -36,6 +36,7 @@ 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"; @@ -70,6 +71,7 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg 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; @@ -124,6 +126,7 @@ export interface CopilotAdapterLiveOptions { readonly baseDirectory?: string; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly turnEndIdleFallbackDelayMs?: number; } interface CopilotTurnSnapshot { @@ -213,6 +216,8 @@ interface CopilotSessionContext { readonly copilotTasks: Map; readonly turnIdsWithAssistantText: Set; readonly startedItemIds: Set; + readonly turnEndEventsByTurnId: Map; + readonly turnEndFallbackTimers: Map>; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; @@ -1256,6 +1261,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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); @@ -1441,6 +1451,22 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + 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, @@ -1451,6 +1477,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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)) { @@ -1459,6 +1486,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } context.completedTurnIds.add(turnId); + context.turnEndEventsByTurnId.delete(turnId); context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); clearSdkTurnMappingsForTurn(context, turnId); @@ -1493,6 +1521,61 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + 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; @@ -2246,6 +2329,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } case "assistant.turn_start": { + await completePendingActiveTurnEnd(context); const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { timestamp: event.timestamp, agentId: event.agentId, @@ -2434,10 +2518,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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": { @@ -2958,6 +3047,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( copilotTasks: new Map(), turnIdsWithAssistantText: new Set(), startedItemIds: new Set(), + turnEndEventsByTurnId: new Map(), + turnEndFallbackTimers: new Map(), activeTurnId: undefined, activeSdkTurnId: undefined, activeSdkTurnKey: undefined, @@ -3052,6 +3143,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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) { @@ -3248,6 +3345,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } context.stopped = true; + cancelTurnEndFallback(context); yield* Effect.promise(() => context.eventChain); yield* settlePendingPermissionHandlers(context, (binding) => emitPermissionRequestResolved(context, binding, "reject", DENIED_PERMISSION_RESULT), From dcbf99a121abdbea663292fefa68dd759dbdbf83 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 13:00:51 +0200 Subject: [PATCH 098/104] Address Copilot provider review comments --- .../src/provider/Layers/CopilotAdapter.ts | 160 +++++++++--------- apps/server/src/provider/copilotRuntime.ts | 17 +- .../textGeneration/CopilotTextGeneration.ts | 49 +++--- 3 files changed, 109 insertions(+), 117 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 363bb5f8b64..71c9717c115 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -357,41 +357,6 @@ function appendTurnItem( ensureTurnSnapshot(context, turnId).items.push(item); } -function processError( - threadId: ThreadId, - detail: string, - cause?: unknown, -): ProviderAdapterProcessError { - return new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function validationError(operation: string, issue: string): ProviderAdapterValidationError { - return new ProviderAdapterValidationError({ - provider: PROVIDER, - operation, - issue, - }); -} - -function sessionClosedError(threadId: ThreadId): ProviderAdapterSessionClosedError { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - }); -} - -function sessionNotFoundError(threadId: ThreadId): ProviderAdapterSessionNotFoundError { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); -} - function detailFromCause(cause: unknown, fallback: string): string { return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; } @@ -411,10 +376,16 @@ function requireSessionContext( return Effect.gen(function* () { const context = sessions.get(threadId); if (!context) { - return yield* sessionNotFoundError(threadId); + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); } if (context.stopped) { - return yield* sessionClosedError(threadId); + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }); } return context; }); @@ -1282,13 +1253,23 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.tryPromise({ try: () => client.start(), catch: (cause) => - processError(threadId, detailFromCause(cause, "Failed to start Copilot client."), 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) => - processError(threadId, detailFromCause(cause, "Failed to stop Copilot client."), cause), + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: detailFromCause(cause, "Failed to stop Copilot client."), + cause, + }), }), createSession: ( threadId: ThreadId, @@ -1298,11 +1279,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.tryPromise({ try: () => client.createSession(config), catch: (cause) => - processError( + new ProviderAdapterProcessError({ + provider: PROVIDER, threadId, - detailFromCause(cause, "Failed to create Copilot session."), + detail: detailFromCause(cause, "Failed to create Copilot session."), cause, - ), + }), }), resumeSession: ( threadId: ThreadId, @@ -1313,11 +1295,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.tryPromise({ try: () => client.resumeSession(sessionId, config), catch: (cause) => - processError( + new ProviderAdapterProcessError({ + provider: PROVIDER, threadId, - detailFromCause(cause, "Failed to resume Copilot session."), + detail: detailFromCause(cause, "Failed to resume Copilot session."), cause, - ), + }), }), setMode: ( context: CopilotSessionContext, @@ -2877,16 +2860,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const startSession: CopilotAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* validationError( - "startSession", - `Expected provider '${PROVIDER}', received '${input.provider}'.`, - ); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}', received '${input.provider}'.`, + }); } if (input.providerInstanceId !== undefined && input.providerInstanceId !== boundInstanceId) { - return yield* validationError( - "startSession", - `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, - ); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider instance '${boundInstanceId}', received '${input.providerInstanceId}'.`, + }); } if (sessions.has(input.threadId)) { @@ -2894,7 +2879,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } if (!settings.enabled) { - return yield* validationError("startSession", "Copilot is disabled in server settings."); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Copilot is disabled in server settings.", + }); } const cwd = path.resolve(input.cwd ?? serverConfig.cwd); @@ -2945,12 +2934,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( platform, logLevel: "error", }).pipe( - Effect.mapError((cause) => - processError( - input.threadId, - detailFromCause(cause, "Failed to configure Copilot client."), - cause, - ), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: detailFromCause(cause, "Failed to configure Copilot client."), + cause, + }), ), ); @@ -3068,12 +3059,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( enqueueSdkEvent(context, event); } yield* Effect.promise(() => context.eventChain).pipe( - Effect.mapError((cause) => - processError( - input.threadId, - detailFromCause(cause, "Failed to process Copilot startup events."), - cause, - ), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: detailFromCause(cause, "Failed to process Copilot startup events."), + cause, + }), ), ); updateProviderSession(context, { @@ -3109,10 +3102,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }); if ((!text || text.length === 0) && attachments.length === 0) { - return yield* validationError( - "sendTurn", - "Copilot turns require text input or at least one attachment.", - ); + 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()}`); @@ -3212,11 +3206,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( errorMessage: error.detail, }), catch: (cause) => - processError( - input.threadId, - detailFromCause(cause, "Failed to emit Copilot turn completion."), + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: detailFromCause(cause, "Failed to emit Copilot turn completion."), cause, - ), + }), }); return yield* error; }), @@ -3288,11 +3283,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( diffText: writeDiff, }), catch: (cause) => - processError( + new ProviderAdapterProcessError({ + provider: PROVIDER, threadId, - detailFromCause(cause, "Failed to emit Copilot write diff update."), + detail: detailFromCause(cause, "Failed to emit Copilot write diff update."), cause, - ), + }), }); } } @@ -3384,7 +3380,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { if (!sessions.has(threadId)) { - return yield* sessionNotFoundError(threadId); + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); } yield* stopSessionInternal(threadId); }, @@ -3412,7 +3411,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, _numTurns) { if (!sessions.has(threadId)) { - return yield* sessionNotFoundError(threadId); + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); } return yield* new ProviderAdapterRequestError({ provider: PROVIDER, diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index f7e686c4166..526ea9f6dc3 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -54,13 +54,14 @@ const COPILOT_SHELL_SPAWN_BACKEND_FLAG = "SHELL_SPAWN_BACKEND"; const COPILOT_SHELL_SPAWN_BACKEND_EXP_ENV = "COPILOT_EXP_COPILOT_CLI_SHELL_SPAWN_BACKEND"; const COPILOT_POSIX_SHELL_CANDIDATES = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; -export class CopilotProbePromiseError extends Error { - override readonly cause: unknown; - - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause)); - this.cause = cause; - this.name = "CopilotProbePromiseError"; +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); } } @@ -107,7 +108,7 @@ export function trimOrUndefined(value: string | null | undefined): string | unde } export function toCopilotProbeError(cause: unknown): CopilotProbePromiseError { - return new CopilotProbePromiseError(cause); + return new CopilotProbePromiseError({ cause }); } function describeCopilotProbeCause(cause: unknown): string { diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index f920f63500b..afb85e0b0ef 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -75,18 +75,6 @@ ${schemaDocument} Do not wrap the JSON in markdown fences or include any other text.`; } -function copilotTextGenerationError( - operation: CopilotTextGenerationOperation, - detail: string, - cause?: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - function detailFromCause(cause: unknown, fallback: string): string { return cause instanceof Error && cause.message.trim().length > 0 ? cause.message : fallback; } @@ -212,12 +200,13 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( platform, logLevel: "error", }).pipe( - Effect.mapError((cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to configure Copilot client."), - cause, - ), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to configure Copilot client."), + cause, + }), ), ); const client = yield* Effect.uninterruptibleMask((restore) => @@ -243,11 +232,11 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }); }), catch: (cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to start Copilot client."), + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to start Copilot client."), cause, - ), + }), }), ); @@ -365,11 +354,11 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }), }), catch: (cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Failed to create Copilot session."), + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to create Copilot session."), cause, - ), + }), }), (session: CopilotSession) => Effect.tryPromise({ @@ -384,11 +373,11 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( return response?.data.content.trim() ?? ""; }, catch: (cause) => - copilotTextGenerationError( - input.operation, - detailFromCause(cause, "Copilot text generation request failed."), + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Copilot text generation request failed."), cause, - ), + }), }), (session) => Effect.tryPromise({ From f6e8dc1936559c0991caa2dafe7695c62f9b4eff Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 13:19:37 +0200 Subject: [PATCH 099/104] Fix Copilot text generation formatting --- apps/server/src/textGeneration/CopilotTextGeneration.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index afb85e0b0ef..aa9c22cb0c0 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -324,9 +324,10 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( input.modelSelection, "reasoningEffort", ) as CopilotReasoningEffort | undefined; - const contextTier = getModelSelectionStringOptionValue(input.modelSelection, "contextTier") as - | CopilotContextTier - | 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. From 0a10280c84d5cb74e4860e898525f5a8022aa3ff Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 13:06:07 +0200 Subject: [PATCH 100/104] refine copilot lifecycle and remove stale awareness fallback --- apps/server/package.json | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 54 +++ .../Layers/ProviderRuntimeIngestion.ts | 47 +++ .../provider/Layers/CopilotAdapter.test.ts | 363 ++++++++++++------ .../src/provider/Layers/CopilotAdapter.ts | 140 +++---- .../textGeneration/CopilotTextGeneration.ts | 7 +- packages/shared/src/agentAwareness.test.ts | 62 +++ packages/shared/src/agentAwareness.ts | 3 + pnpm-lock.yaml | 111 +++++- 9 files changed, 576 insertions(+), 213 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 1b527825128..6a39418dda6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,7 +29,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@github/copilot": "1.0.60", - "@github/copilot-sdk": "1.0.0", + "@github/copilot-sdk": "1.0.5", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 616eced0d37..951b31cc93d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -27,7 +27,9 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Logger from "effect/Logger"; import * as PubSub from "effect/PubSub"; +import * as References from "effect/References"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it } from "vite-plus/test"; @@ -170,6 +172,10 @@ type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number]; type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number]; type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number]; type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number]; +type ProviderRuntimeCapturedLog = { + readonly message: unknown; + readonly annotations: Record; +}; async function waitForThread( readModel: () => Promise, @@ -225,6 +231,13 @@ describe("ProviderRuntimeIngestion", () => { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); + const logs: ProviderRuntimeCapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), @@ -244,6 +257,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(Logger.layer([logger])), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); @@ -320,6 +334,7 @@ describe("ProviderRuntimeIngestion", () => { setProviderSession: provider.setSession, clearProviderSessions: provider.clearSessions, drain, + logs, }; } @@ -805,6 +820,45 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("emits warning telemetry when strict lifecycle guard rejects a conflicting turn.started", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-telemetry-active"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-telemetry-active"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-telemetry-active", + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-telemetry-stale"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-telemetry-stale"), + }); + + await harness.drain(); + + const rejectionLog = harness.logs.find( + (entry) => + JSON.stringify(entry).includes("provider runtime lifecycle event rejected") && + JSON.stringify(entry).includes("conflicting turn.started does not match active turn"), + ); + expect(rejectionLog).toBeDefined(); + }); + it("maps canonical content delta/item completed into finalized assistant messages", 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 5b65ccbb4fc..d4e3b902ec7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1358,6 +1358,53 @@ const make = Effect.gen(function* () { return true; } })(); + + const lifecycleGuardRejectionReason = (() => { + if (shouldApplyThreadLifecycle) { + return null; + } + if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { + return null; + } + if (event.type === "turn.started") { + return conflictsWithActiveTurn + ? "conflicting turn.started does not match active turn" + : "turn.started rejected by strict lifecycle guard"; + } + if (event.type === "turn.completed") { + if (conflictsWithActiveTurn) { + return "conflicting turn.completed does not match active turn"; + } + if (activeTurnId !== null && lifecycleEventTurnId === undefined) { + if (unscopedCompletionHasNoProviderSession) { + return "unscoped turn.completed rejected: provider session missing"; + } + if (providerActiveTurnId !== undefined && !sameId(activeTurnId, providerActiveTurnId)) { + return "unscoped turn.completed rejected: provider active turn differs"; + } + } + return "turn.completed rejected by strict lifecycle guard"; + } + return "lifecycle event rejected by strict lifecycle guard"; + })(); + + if ( + lifecycleGuardRejectionReason !== null && + (event.type === "turn.started" || event.type === "turn.completed") + ) { + yield* Effect.logWarning("provider runtime lifecycle event rejected", { + eventId: event.eventId, + eventType: event.type, + provider: event.provider, + threadId: thread.id, + reason: lifecycleGuardRejectionReason, + activeTurnId, + eventTurnId: lifecycleEventTurnId, + providerActiveTurnId: providerActiveTurnId ?? null, + strictLifecycleGuard: STRICT_PROVIDER_LIFECYCLE_GUARD, + }); + } + const acceptedTurnStartedSourcePlan = event.type === "turn.started" && shouldApplyThreadLifecycle ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 57a419e78e3..f8a12554e12 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -147,7 +147,6 @@ const CopilotAdapterTestLayer = Layer.effect( CopilotAdapter, makeCopilotAdapter(decodeCopilotSettings({}), { nativeEventLogger, - turnEndIdleFallbackDelayMs: 25, }), ).pipe( Layer.provideMerge( @@ -1132,7 +1131,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-second", }, } as SessionEvent); - yield* TestClock.adjust("25 millis"); + emit({ + id: "evt-copilot-second-session-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); for ( let attempt = 0; @@ -3418,7 +3425,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("completes a final assistant turn when Copilot omits session idle", () => + it.effect("completes a final assistant turn on session.idle", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-final-turn-end-without-idle"); @@ -3477,7 +3484,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-final-turn-end", }, } as SessionEvent); - yield* TestClock.adjust("25 millis"); + emit({ + id: "evt-copilot-final-turn-end-session-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); for ( let attempt = 0; @@ -3956,6 +3971,116 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("completes the active turn on session.idle", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-session-idle-completion"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "finish on assistant idle", + 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-transient-idle-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-transient-idle", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-transient-idle-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-transient-idle", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-session-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(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); + const idleStateChanged = runtimeEvents.find( + (event) => event.type === "session.state.changed" && event.payload.state === "ready", + ); + NodeAssert.ok(idleStateChanged); + + emit({ + id: "evt-copilot-session-idle-duplicate", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => event.type === "session.state.changed" && event.payload.state === "ready", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + const completionsAfterDuplicateIdle = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(completionsAfterDuplicateIdle.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; @@ -4191,128 +4316,137 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - 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); + it.effect("maps the next turn correctly after first completion clears stale sdk turn state", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-idle-only-next-turn-mapping"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); - 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); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + 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-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + 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); + yield* TestClock.adjust("25 millis"); - const firstTurnCompletions = runtimeEvents.filter( + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( (event) => event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), ); - const secondTurnCompletions = runtimeEvents.filter( + 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); + yield* TestClock.adjust("25 millis"); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( (event) => event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), ); - NodeAssert.equal(firstTurnCompletions.length, 1); - NodeAssert.equal(secondTurnCompletions.length, 1); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } - yield* adapter.stopSession(threadId); - }), + 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", () => @@ -4380,6 +4514,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const disconnectsBeforeDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; releaseNativeWrite(); + yield* TestClock.adjust("25 millis"); yield* Fiber.join(stopFiber); for ( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 71c9717c115..c91ac31d6ff 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -36,7 +36,6 @@ 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"; @@ -71,7 +70,6 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg 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; @@ -126,7 +124,6 @@ export interface CopilotAdapterLiveOptions { readonly baseDirectory?: string; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; - readonly turnEndIdleFallbackDelayMs?: number; } interface CopilotTurnSnapshot { @@ -217,7 +214,6 @@ interface CopilotSessionContext { readonly turnIdsWithAssistantText: Set; readonly startedItemIds: Set; readonly turnEndEventsByTurnId: Map; - readonly turnEndFallbackTimers: Map>; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; @@ -1232,11 +1228,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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); @@ -1434,22 +1425,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - 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, @@ -1460,7 +1435,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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)) { @@ -1504,13 +1478,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - const shouldCompleteOnAssistantTurnEnd = ( - context: CopilotSessionContext, - turnId: TurnId, - ): boolean => - context.activeTurnId === turnId && - (context.turnIdsWithAssistantText.has(turnId) || - context.pendingTaskCompletionTextByTurnId.has(turnId)); + const hasTurnCompletionSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => + context.turnEndEventsByTurnId.has(turnId) || + context.turnIdsWithAssistantText.has(turnId) || + context.pendingTaskCompletionTextByTurnId.has(turnId); + + const shouldCompleteOnIdleSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => + hasTurnCompletionSignal(context, turnId); const completePendingActiveTurnEnd = async (context: CopilotSessionContext) => { const turnId = context.activeTurnId; @@ -1530,33 +1504,27 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - const scheduleTurnEndFallback = ( + const emitSessionIdleStateChanged = async ( 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); + event: Extract, + ): Promise => { + 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.", + }, + }); }; const emitTurnDiffUpdated = async (input: { @@ -2192,31 +2160,27 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( case "session.idle": { if (context.activeTurnId) { const turnId = context.activeTurnId; - if (!event.data.aborted) { + if (event.data.aborted) { + await emitTurnCompleted(context, turnId, "cancelled", { + raw: event, + stopReason: "aborted", + }); + await emitSessionIdleStateChanged(context, event); + return; + } + if (shouldCompleteOnIdleSignal(context, turnId)) { await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); + await emitTurnCompleted(context, turnId, "completed", { + raw: event, + stopReason: null, + }); + await emitSessionIdleStateChanged(context, event); + return; } - await emitTurnCompleted(context, turnId, event.data.aborted ? "cancelled" : "completed", { - raw: event, - stopReason: event.data.aborted ? "aborted" : null, - }); + // Ignore stale/early idle events that are not tied to this active turn. + return; } - 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.", - }, - }); + await emitSessionIdleStateChanged(context, event); return; } case "session.title_changed": { @@ -2502,14 +2466,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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": { @@ -3039,7 +2999,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnIdsWithAssistantText: new Set(), startedItemIds: new Set(), turnEndEventsByTurnId: new Map(), - turnEndFallbackTimers: new Map(), activeTurnId: undefined, activeSdkTurnId: undefined, activeSdkTurnKey: undefined, @@ -3341,8 +3300,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } context.stopped = true; - cancelTurnEndFallback(context); yield* Effect.promise(() => context.eventChain); + const activeTurnId = context.activeTurnId; + if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { + yield* Effect.promise(() => + emitTurnCompleted(context, activeTurnId, "completed", { + stopReason: null, + }), + ).pipe(Effect.ignore); + } yield* settlePendingPermissionHandlers(context, (binding) => emitPermissionRequestResolved(context, binding, "reject", DENIED_PERMISSION_RESULT), ); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index aa9c22cb0c0..afb85e0b0ef 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -324,10 +324,9 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( input.modelSelection, "reasoningEffort", ) as CopilotReasoningEffort | undefined; - const contextTier = getModelSelectionStringOptionValue( - input.modelSelection, - "contextTier", - ) as CopilotContextTier | 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. diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 217e0016352..450ccdae6ef 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -102,6 +102,68 @@ describe("projectThreadAwareness", () => { }); }); + it("keeps running threads in running phase even when updates are delayed", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + updatedAt: "2026-05-22T12:00:00.000Z", + session: { + threadId: "thread-1" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: "2026-05-22T12:00:00.000Z", + }, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: "2026-05-22T12:00:00.000Z", + startedAt: "2026-05-22T12:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }), + }); + + expect(state).toMatchObject({ + phase: "running", + headline: "Agent is working", + detail: "Codex is active.", + }); + }); + + it("keeps recently updated running threads in running phase", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + updatedAt: "2026-05-22T12:00:00.000Z", + session: { + threadId: "thread-1" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: "2026-05-22T12:00:00.000Z", + }, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: "2026-05-22T12:00:00.000Z", + startedAt: "2026-05-22T12:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }), + }); + + expect(state?.phase).toBe("running"); + }); + it("projects failures with the session error detail", () => { const state = projectThreadAwareness({ environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 6831e8ba301..14ef09c403c 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -138,5 +138,8 @@ function detailForPhase( if (phase === "running" && thread.session?.providerName) { return `${thread.session.providerName} is active.`; } + if (phase === "stale") { + return "No lifecycle updates received recently."; + } return undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be8f21f8335..1e0c04547e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,8 +466,8 @@ importers: specifier: 1.0.60 version: 1.0.60 '@github/copilot-sdk': - specifier: 1.0.0 - version: 1.0.0 + specifier: 1.0.5 + version: 1.0.5 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2704,39 +2704,83 @@ packages: os: [darwin] hasBin: true + '@github/copilot-darwin-arm64@1.0.68': + resolution: {integrity: sha512-0G26AL9dlrwTa5IRTxPEnkX6Kz20CuetIwXzABmWwiXYcsR9rswM/NYICR3k53TOa0zL7aqFPnl98CW7J5XbZw==} + 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-darwin-x64@1.0.68': + resolution: {integrity: sha512-RoClWH4CPH19pv5jrR0E6pBU6ljgrzL7idb7tV2pPtMYGTuwqW//XrIEE4n/5NVZmhzEiVBeq04THzOBeV493A==} + cpu: [x64] + os: [darwin] + hasBin: true + '@github/copilot-linux-arm64@1.0.60': resolution: {integrity: sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==} cpu: [arm64] os: [linux] + libc: [glibc] + hasBin: true + + '@github/copilot-linux-arm64@1.0.68': + resolution: {integrity: sha512-SXSOz/2xPeUM/ndKypBiQv+QGYaEM7oGytl1i+Yx4tJnOoIwLkkTmIaWUbBNn0n5DTEjUcWyDqHzxxo/42FKRQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] hasBin: true '@github/copilot-linux-x64@1.0.60': resolution: {integrity: sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==} cpu: [x64] os: [linux] + libc: [glibc] + hasBin: true + + '@github/copilot-linux-x64@1.0.68': + resolution: {integrity: sha512-YdG1chniWyps7XEJ2YHUOJkcOc6BpDQZby/zOKCVdswzRXx7d3WiZ2P9lfDimBBmXXJEJ81Fqhv2ZK5eOmGlUw==} + cpu: [x64] + os: [linux] + libc: [glibc] hasBin: true '@github/copilot-linuxmusl-arm64@1.0.60': resolution: {integrity: sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==} cpu: [arm64] os: [linux] + libc: [musl] + hasBin: true + + '@github/copilot-linuxmusl-arm64@1.0.68': + resolution: {integrity: sha512-LTYZFOHpeLg4rCtsq3A/LMZxxRKFcCLmhnt8F7ovNYLNDJMkh3xdYanoXP0C0PO3uyUMiNJ+p5YXhZiBuo2yfw==} + cpu: [arm64] + os: [linux] + libc: [musl] hasBin: true '@github/copilot-linuxmusl-x64@1.0.60': resolution: {integrity: sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==} cpu: [x64] os: [linux] + libc: [musl] hasBin: true - '@github/copilot-sdk@1.0.0': - resolution: {integrity: sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==} - engines: {node: '>=20.0.0'} + '@github/copilot-linuxmusl-x64@1.0.68': + resolution: {integrity: sha512-oOMXZ9HPJAJaKSrXZIYuond4uOiUkK1uRhVPI3Cs74n7uEVKPWpNlTN+j/O754dnBa1+HJcuZSDMulTbnOgirg==} + cpu: [x64] + os: [linux] + libc: [musl] + hasBin: true + + '@github/copilot-sdk@1.0.5': + resolution: {integrity: sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==} + engines: {node: ^20.19.0 || >=22.12.0} '@github/copilot-win32-arm64@1.0.60': resolution: {integrity: sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==} @@ -2744,16 +2788,32 @@ packages: os: [win32] hasBin: true + '@github/copilot-win32-arm64@1.0.68': + resolution: {integrity: sha512-ZDqpJMP9Y5vqwvRxnIvZrVl8ibx/P66m3JTXQuzv6pitq7rkMEuNKscZ7cjJYN4N+BCOF5++5LKw8O1WHzXAAA==} + 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-win32-x64@1.0.68': + resolution: {integrity: sha512-eplj/Y2B+amMLJ37oNE6G8gx85j8ucAuJz+CjzpzprNiBUq45lFL8ukGeDtaLMRvIeYAEDYdz5yUzu2XtCE7mA==} + cpu: [x64] + os: [win32] + hasBin: true + '@github/copilot@1.0.60': resolution: {integrity: sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==} hasBin: true + '@github/copilot@1.0.68': + resolution: {integrity: sha512-2VPcTlW0RAEsfeS0Ma2ICCkfXgpxy3NL7+SReR8gzvEEPiokSRf0k5JBPlgMbBEFvocSRcJ01S8KvBm84Dw+Fw==} + hasBin: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -12549,33 +12609,57 @@ snapshots: '@github/copilot-darwin-arm64@1.0.60': optional: true + '@github/copilot-darwin-arm64@1.0.68': + optional: true + '@github/copilot-darwin-x64@1.0.60': optional: true + '@github/copilot-darwin-x64@1.0.68': + optional: true + '@github/copilot-linux-arm64@1.0.60': optional: true + '@github/copilot-linux-arm64@1.0.68': + optional: true + '@github/copilot-linux-x64@1.0.60': optional: true + '@github/copilot-linux-x64@1.0.68': + optional: true + '@github/copilot-linuxmusl-arm64@1.0.60': optional: true + '@github/copilot-linuxmusl-arm64@1.0.68': + optional: true + '@github/copilot-linuxmusl-x64@1.0.60': optional: true - '@github/copilot-sdk@1.0.0': + '@github/copilot-linuxmusl-x64@1.0.68': + optional: true + + '@github/copilot-sdk@1.0.5': dependencies: - '@github/copilot': 1.0.60 + '@github/copilot': 1.0.68 vscode-jsonrpc: 8.2.1 zod: 4.4.3 '@github/copilot-win32-arm64@1.0.60': optional: true + '@github/copilot-win32-arm64@1.0.68': + optional: true + '@github/copilot-win32-x64@1.0.60': optional: true + '@github/copilot-win32-x64@1.0.68': + optional: true + '@github/copilot@1.0.60': dependencies: detect-libc: 2.1.2 @@ -12589,6 +12673,19 @@ snapshots: '@github/copilot-win32-arm64': 1.0.60 '@github/copilot-win32-x64': 1.0.60 + '@github/copilot@1.0.68': + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.68 + '@github/copilot-darwin-x64': 1.0.68 + '@github/copilot-linux-arm64': 1.0.68 + '@github/copilot-linux-x64': 1.0.68 + '@github/copilot-linuxmusl-arm64': 1.0.68 + '@github/copilot-linuxmusl-x64': 1.0.68 + '@github/copilot-win32-arm64': 1.0.68 + '@github/copilot-win32-x64': 1.0.68 + '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: hono: 4.12.27 From 4996a2d1549c7aff95449c224c1e554090e2b62e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:07:06 +0200 Subject: [PATCH 101/104] Harden provider runtime lifecycle handling --- .../Layers/ProviderCommandReactor.test.ts | 46 +++++++++- .../Layers/ProviderCommandReactor.ts | 39 ++++++-- .../Layers/ProviderRuntimeIngestion.ts | 25 +++++- .../provider/Layers/CopilotAdapter.test.ts | 90 ++++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 79 +++++++++++++++- 5 files changed, 264 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b104afccb88..af97c78e83d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -920,6 +920,44 @@ describe("ProviderCommandReactor", () => { expect(harness.generateThreadTitle).not.toHaveBeenCalled(); }); + it("does not replace user-edited titles that match a truncated seed without ellipsis", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + const editedTitle = + "Please investigate reconnect failures after restarting the session and make startup reliable (urgent)"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-edited-no-ellipsis-seed"), + threadId: ThreadId.make("thread-1"), + title: editedTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-edited-no-ellipsis-seed"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-edited-no-ellipsis-seed"), + role: "user", + text: "Please investigate reconnect failures after restarting the session and make startup reliable.", + 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"; @@ -2139,7 +2177,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-approval"), @@ -2157,7 +2195,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond"), @@ -2180,7 +2218,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-user-input"), @@ -2198,7 +2236,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5deaba24d33..a76bddf0ccd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -104,7 +104,15 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { +function normalizeTitleSeedCandidate(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function canReplaceThreadTitle( + currentTitle: string, + titleSeed?: string, + sourceMessageText?: string, +): boolean { const trimmedCurrentTitle = currentTitle.trim(); if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { return true; @@ -115,12 +123,31 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea return false; } + if (trimmedCurrentTitle === trimmedTitleSeed) { + return true; + } + + // Truncated seed expansion matching is only valid for client titles that + // explicitly use an ellipsis marker. + if (!trimmedTitleSeed.endsWith("...")) { + return false; + } + + const normalizedCurrentTitle = normalizeTitleSeedCandidate(trimmedCurrentTitle); + const normalizedSourceMessage = sourceMessageText + ? normalizeTitleSeedCandidate(sourceMessageText) + : undefined; + const seededFromExpandedPrompt = + normalizedSourceMessage !== undefined && normalizedCurrentTitle === normalizedSourceMessage; + if (!seededFromExpandedPrompt) { + return false; + } + const currentLooksUserEditedFromTruncatedSeed = trimmedTitleSeed.endsWith("...") && trimmedCurrentTitle.includes("..."); return ( - trimmedCurrentTitle === trimmedTitleSeed || - (!currentLooksUserEditedFromTruncatedSeed && - truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed) + !currentLooksUserEditedFromTruncatedSeed && + truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed ); } @@ -731,7 +758,7 @@ const make = Effect.gen(function* () { const thread = yield* resolveThread(input.threadId); if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { + if (!canReplaceThreadTitle(thread.title, input.titleSeed, input.messageText)) { return; } @@ -794,7 +821,7 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed, message.text)) { yield* firstTurnAuxiliaryWorker.enqueue( maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d4e3b902ec7..0aaee21aa33 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1295,14 +1295,33 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; const lifecycleEventTurnId = eventTurnId; + let providerSessionLookupFailed = false; const providerSessionForUnscopedCompletion = event.type === "turn.completed" && eventTurnId === undefined - ? yield* getProviderSessionForThread(thread.id) + ? yield* getProviderSessionForThread(thread.id).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion could not load provider session for unscoped turn completion", + { + threadId: thread.id, + cause: Cause.pretty(cause), + }, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + providerSessionLookupFailed = true; + }), + ), + Effect.as(undefined), + ), + ), + ) : undefined; const providerActiveTurnId = providerSessionForUnscopedCompletion?.activeTurnId; const unscopedCompletionHasNoProviderSession = event.type === "turn.completed" && eventTurnId === undefined && + !providerSessionLookupFailed && providerSessionForUnscopedCompletion === undefined; const conflictsWithActiveTurn = @@ -1680,7 +1699,9 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; - const turnId = toTurnId(event.turnId) ?? (shouldApplyThreadLifecycle ? activeTurnId : null); + const turnId = + toTurnId(event.turnId) ?? + (shouldApplyThreadLifecycle ? (activeTurnId ?? providerActiveTurnId ?? null) : null); if (turnId) { const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); yield* Effect.forEach( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index f8a12554e12..e77c5637edd 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -147,6 +147,7 @@ const CopilotAdapterTestLayer = Layer.effect( CopilotAdapter, makeCopilotAdapter(decodeCopilotSettings({}), { nativeEventLogger, + turnEndIdleFallbackDelayMs: 25, }), ).pipe( Layer.provideMerge( @@ -3520,6 +3521,93 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("completes a final assistant turn when session.idle is missing", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-final-turn-end-missing-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 without idle", + 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-missing-idle-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-final-turn-end-missing-idle", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-final-turn-end-missing-idle-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-final-turn-end-missing-idle", + content: "Finished without a session idle event.", + turnId: "sdk-turn-final-turn-end-missing-idle", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-final-turn-end-missing-idle-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-final-turn-end-missing-idle", + }, + } 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; @@ -4535,7 +4623,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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"); + NodeAssert.equal(completed.payload.state, "interrupted"); } }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index c91ac31d6ff..8b773ab1225 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -36,6 +36,7 @@ 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"; @@ -70,6 +71,7 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg 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; @@ -124,6 +126,7 @@ export interface CopilotAdapterLiveOptions { readonly baseDirectory?: string; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly turnEndIdleFallbackDelayMs?: number; } interface CopilotTurnSnapshot { @@ -214,6 +217,7 @@ interface CopilotSessionContext { readonly turnIdsWithAssistantText: Set; readonly startedItemIds: Set; readonly turnEndEventsByTurnId: Map; + readonly turnEndFallbackTimers: Map>; activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; @@ -1228,6 +1232,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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); @@ -1425,6 +1434,22 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + 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, @@ -1435,6 +1460,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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)) { @@ -1483,6 +1509,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.turnIdsWithAssistantText.has(turnId) || context.pendingTaskCompletionTextByTurnId.has(turnId); + const shouldCompleteOnAssistantTurnEnd = ( + context: CopilotSessionContext, + turnId: TurnId, + ): boolean => + context.activeTurnId === turnId && + (context.turnIdsWithAssistantText.has(turnId) || + context.pendingTaskCompletionTextByTurnId.has(turnId)); + const shouldCompleteOnIdleSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => hasTurnCompletionSignal(context, turnId); @@ -1504,6 +1538,35 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + 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 emitSessionIdleStateChanged = async ( context: CopilotSessionContext, event: Extract, @@ -1618,7 +1681,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const emitPendingTaskCompletionAsAssistantMessage = async ( context: CopilotSessionContext, turnId: TurnId, - raw: SessionEvent, + raw?: SessionEvent, ) => { const content = context.pendingTaskCompletionTextByTurnId.get(turnId); if (!content || context.turnIdsWithAssistantText.has(turnId)) { @@ -2277,6 +2340,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "assistant.turn_start": { await completePendingActiveTurnEnd(context); + if (context.activeTurnId) { + cancelTurnEndFallback(context, context.activeTurnId); + } const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { timestamp: event.timestamp, agentId: event.agentId, @@ -2466,10 +2532,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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": { @@ -2999,6 +3069,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnIdsWithAssistantText: new Set(), startedItemIds: new Set(), turnEndEventsByTurnId: new Map(), + turnEndFallbackTimers: new Map(), activeTurnId: undefined, activeSdkTurnId: undefined, activeSdkTurnKey: undefined, @@ -3304,11 +3375,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const activeTurnId = context.activeTurnId; if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { yield* Effect.promise(() => - emitTurnCompleted(context, activeTurnId, "completed", { + emitPendingTaskCompletionAsAssistantMessage(context, activeTurnId), + ).pipe(Effect.ignore); + yield* Effect.promise(() => + emitTurnCompleted(context, activeTurnId, "interrupted", { stopReason: null, }), ).pipe(Effect.ignore); } + cancelTurnEndFallback(context); yield* settlePendingPermissionHandlers(context, (binding) => emitPermissionRequestResolved(context, binding, "reject", DENIED_PERMISSION_RESULT), ); From 394753c3d186538410b98b3f7e0d837f07f14d5b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:24:20 +0200 Subject: [PATCH 102/104] Fix review feedback on turn completion guards --- .../Layers/ProviderRuntimeIngestion.ts | 11 +--------- .../provider/Layers/CopilotAdapter.test.ts | 21 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 10 ++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0aaee21aa33..a838d9463a2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1295,7 +1295,6 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; const lifecycleEventTurnId = eventTurnId; - let providerSessionLookupFailed = false; const providerSessionForUnscopedCompletion = event.type === "turn.completed" && eventTurnId === undefined ? yield* getProviderSessionForThread(thread.id).pipe( @@ -1306,14 +1305,7 @@ const make = Effect.gen(function* () { threadId: thread.id, cause: Cause.pretty(cause), }, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - providerSessionLookupFailed = true; - }), - ), - Effect.as(undefined), - ), + ).pipe(Effect.as(undefined)), ), ) : undefined; @@ -1321,7 +1313,6 @@ const make = Effect.gen(function* () { const unscopedCompletionHasNoProviderSession = event.type === "turn.completed" && eventTurnId === undefined && - !providerSessionLookupFailed && providerSessionForUnscopedCompletion === undefined; const conflictsWithActiveTurn = diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index e77c5637edd..56b36d288f1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -3494,6 +3494,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + yield* TestClock.adjust("300 millis"); for ( let attempt = 0; @@ -3647,6 +3648,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-first-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-multi-loop-first", + content: "First loop partial output.", + turnId: "sdk-turn-multi-loop-first", + }, + } as SessionEvent); emit({ id: "evt-copilot-multi-loop-first-end", timestamp, @@ -3656,6 +3668,15 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-idle-between-loops", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); yield* waitForSdkEventQueue(); NodeAssert.equal( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 8b773ab1225..f911a711827 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -72,6 +72,7 @@ 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; +const IDLE_TURN_COMPLETION_DEBOUNCE_MS = 250; type CopilotMode = "interactive" | "plan" | "autopilot"; type CopilotReasoningEffort = NonNullable; @@ -1542,10 +1543,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context: CopilotSessionContext, turnId: TurnId, raw: SessionEvent, + delayMs = turnEndIdleFallbackDelayMs, ): void => { cancelTurnEndFallback(context, turnId); const fiber = runFork( - Effect.sleep(`${turnEndIdleFallbackDelayMs} millis`).pipe( + Effect.sleep(`${delayMs} millis`).pipe( Effect.andThen( Effect.promise(async () => { context.turnEndFallbackTimers.delete(turnId); @@ -2232,6 +2234,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } if (shouldCompleteOnIdleSignal(context, turnId)) { + if (context.turnEndFallbackTimers.has(turnId)) { + // Copilot may emit an idle pulse between SDK loops; debounce + // completion so the next assistant.turn_start can cancel it. + scheduleTurnEndFallback(context, turnId, event, IDLE_TURN_COMPLETION_DEBOUNCE_MS); + return; + } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, event); await emitTurnCompleted(context, turnId, "completed", { raw: event, From 07f5e629582a68ca25c27e1451afec13d86e1908 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:47:15 +0200 Subject: [PATCH 103/104] Fix Copilot probe error message and flaky idle test --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 1 + apps/server/src/provider/copilotRuntime.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 56b36d288f1..aa66205c308 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1141,6 +1141,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + yield* TestClock.adjust("300 millis"); for ( let attempt = 0; diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 526ea9f6dc3..c4e42d52a2c 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -61,7 +61,7 @@ export class CopilotProbePromiseError extends Schema.TaggedErrorClass Date: Mon, 6 Jul 2026 15:09:27 +0200 Subject: [PATCH 104/104] Unwrap Copilot probe wrapper errors for status --- apps/server/src/provider/copilotRuntime.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index c4e42d52a2c..090a6894486 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -123,6 +123,10 @@ function describeCopilotProbeCause(cause: unknown): string { const detail = "detail" in current && typeof current.detail === "string" ? current.detail.trim() : ""; const nestedCause = "cause" in current ? current.cause : undefined; + if (tag === "CopilotProbePromiseError" && nestedCause !== undefined) { + current = nestedCause; + continue; + } if (tag === "CopilotCliPathResolutionError" && nestedCause !== undefined) { if (detail.length > 0) { fallbackMessages.push(detail);