From f00b461c0ca0e8caff1c94d698c3a053c8d95b9a Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:15:48 +0200 Subject: [PATCH 001/155] 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 d0903c77d75a..27a7fead3506 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 000000000000..8817865255eb --- /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 000000000000..97ef9d9169b0 --- /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 000000000000..76a0f104bd1e --- /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 000000000000..24255ef2a70f --- /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 000000000000..2dddc77a128d --- /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 791a96e1da3c..a296427bcf1a 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 000000000000..757c54493387 --- /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 000000000000..4f0c8c383621 --- /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 e62a79afe787..65ed0755186f 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 e5555cb01153..5dda241910da 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 f9e7a7007168..1c18c4e17233 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 77c1813f1108..7a3637eb2394 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 bfee6a8d6807..6a672785acbe 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 5d5051f748e7..69b0aa967b2e 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 dddf3f374598..60e02cb19e0a 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 eb2563eff004..b2df56b94518 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 b05f397bf5ca..9436e251f837 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 743aa71c6ad6e4a868a6eb45562e841a843e6d4c Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:27:31 +0200 Subject: [PATCH 002/155] 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 78ff5cf493d6..1a39d01176ca 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1437,6 +1437,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 8a0431f2da377fefabbb93d624361ec6e37b7c2d Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 18 Apr 2026 22:39:10 +0200 Subject: [PATCH 003/155] 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 000000000000..40d0dfef13e2 --- /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 d93faebd0e2a47e918d6d5ceca1d416764cc9430 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 09:41:31 +0200 Subject: [PATCH 004/155] 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 000000000000..798e98e5c91f --- /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 000000000000..23b257fd170a --- /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 a2f94a0007614f4abeded571954c602a51a32e5e Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:06:41 +0200 Subject: [PATCH 005/155] 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 000000000000..8b2a03b3afdb --- /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 28be1d0ea60d3102354a22d342f27b6710242a6b Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:23:52 +0200 Subject: [PATCH 006/155] 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 000000000000..f57d8ebe491c --- /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 b6de89180b1309718f823384eafcd0af89c3e5a5 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 20 Apr 2026 10:26:09 +0200 Subject: [PATCH 007/155] 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 8b2a03b3afdb..000000000000 --- 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 3cb707bb86a27964bfc18c610e741775245012d0 Mon Sep 17 00:00:00 2001 From: Zortos Date: Wed, 22 Apr 2026 12:56:11 +0200 Subject: [PATCH 008/155] 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 40d0dfef13e2..2f889945be55 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 6892fcd61fb1246642b5f347c2cfc5924ff5b199 Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 7 May 2026 15:02:19 +0200 Subject: [PATCH 009/155] 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 2f889945be55..d21011343430 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 ed4c30da2f459eba1ce431a78833f1b0395db36d Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 7 May 2026 15:45:45 +0200 Subject: [PATCH 010/155] 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 d21011343430..8a95d7eb9be2 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 97ef9d9169b0..0ca3ddb09933 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 cbe235f4bf467403086a4ebc35f16b0463d5bbda Mon Sep 17 00:00:00 2001 From: Zortos Date: Fri, 8 May 2026 09:07:37 +0200 Subject: [PATCH 011/155] 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 2dddc77a128d..000000000000 --- 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 ab72e4c3a13f9e60ca45c440cdb44e6d86bc8f56 Mon Sep 17 00:00:00 2001 From: Zortos Date: Mon, 11 May 2026 21:25:59 +0200 Subject: [PATCH 012/155] 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 8a95d7eb9be2..1d9883441b96 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 0ca3ddb09933..1270cc90ca2f 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 10bf62019969a0e3f0aa010ebde2db9017ae3ed0 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 16 May 2026 17:53:00 +0200 Subject: [PATCH 013/155] 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 1d9883441b96..91169c3ee043 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 1270cc90ca2f..f8368252e5e5 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 7eca7d6592c866240cfc5865150fa822d1463ed0 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 11:48:05 +0200 Subject: [PATCH 014/155] 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 8817865255eb..dedfcf2b02cc 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 91169c3ee043..9bb883a9813b 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 f8368252e5e5..fcd56745661a 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 76a0f104bd1e..19625924b5f9 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 4f0c8c383621..8a5f4e8c64ed 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 470b28aebede1a1859d7b1955538c6a100d258f2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 12:14:26 +0200 Subject: [PATCH 015/155] 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 fcd56745661a..c921eb2c6e14 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 798e98e5c91f..2532a8f8df4f 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 19625924b5f9..d0d4f35398ed 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 23b257fd170a..43067b8af174 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 757c54493387..b999191cec44 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 8a5f4e8c64ed..a4b857bddb06 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 7ff15274cac990381fa7027564bf85fc456ffa65 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 31 May 2026 12:56:15 +0200 Subject: [PATCH 016/155] 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 43067b8af174..85bfe2b33695 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 b999191cec44..64fd44a1faa4 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 ac2f7be81e83..a9086b026d6b 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 8059cac62839cb73441e555c1cfe03030e1420a6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 1 Jun 2026 10:53:47 +0200 Subject: [PATCH 017/155] 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 85bfe2b33695..d78488191612 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 64fd44a1faa4..17bf06d413ed 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 2bb4d2a5bbab33d1fc2c811469bc19456909660f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 10:42:31 +0200 Subject: [PATCH 018/155] 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 dedfcf2b02cc..27c7bc4bf9b5 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 17bf06d413ed..be4352fecfd9 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 a4b857bddb06..9f00d772641c 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 a9086b026d6b..65484eb595b5 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 c58ccffc4200..c0b40028d18c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,8 +454,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 @@ -2696,6 +2699,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'} @@ -3234,6 +3293,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==} @@ -8416,6 +8490,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==} @@ -10147,6 +10226,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==} @@ -12698,6 +12781,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 @@ -13288,6 +13419,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': {} @@ -19196,6 +19336,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)): @@ -21319,6 +21467,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 666793b331d009b39dcfd2657fc75b15ab38b2fd Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 11:32:00 +0200 Subject: [PATCH 019/155] 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 27a7fead3506..8ac31679144e 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 d78488191612..da180421a5a9 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 be4352fecfd9..52954a312660 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 c0b40028d18c..b7868a5c34df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,9 +456,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 @@ -2699,38 +2702,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 @@ -2739,20 +2742,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': @@ -3293,21 +3296,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==} @@ -8490,11 +8478,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==} @@ -12781,53 +12764,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: @@ -13419,15 +13397,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': {} @@ -19336,14 +19305,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 da45376954f2..7bc4086bad3a 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -173,6 +173,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( createStagePatchedDependencies( From 468794d667db73b119eec47732469dec4e7ce9fb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 14:38:09 +0200 Subject: [PATCH 020/155] 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 8ac31679144e..917c92bf7d56 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 27c7bc4bf9b5..d5c11e7698c2 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 9bb883a9813b..c6f01820ef2d 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 c921eb2c6e14..5e779cf3377e 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 d0d4f35398ed..90987b86ffa8 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 da180421a5a9..30192e94bef1 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 52954a312660..e5f950a9a000 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 f57d8ebe491c..a5f7ecddb271 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 9f00d772641c..4ed96c0b80ed 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 b7868a5c34df..629c947afbd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,8 +460,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 @@ -2738,8 +2738,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': @@ -12782,7 +12782,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 7bc4086bad3a..030c56a6af84 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -178,14 +178,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 a91622849964167e83a87e36e3538f7dfa593eba Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:14:15 +0200 Subject: [PATCH 021/155] 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 c6f01820ef2d..c6c8d62898e3 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 5e779cf3377e..d6a838f017f6 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 30192e94bef1..e806d825000d 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 e5f950a9a000..ee909b644331 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 714ee4239f39..8777e28d80da 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -46,6 +46,7 @@ type TraitsPersistence = }; const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; +const CONTEXT_WINDOW_DESCRIPTOR_IDS = new Set(["contextWindow", "contextTier"]); function DefaultBadge() { return ( @@ -111,7 +112,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 067e71ef1bfc..73bbba188ba4 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 1349e2509b7b..c1b0e71f8366 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 735711c338116e4cb781adca7f4ad75dee79d03a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:20:12 +0200 Subject: [PATCH 022/155] 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 e806d825000d..db125de74100 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 ee909b644331..a7a9f89332f1 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 038d5a0bf171c28e0ab6b1be724f039d3349c355 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:57:10 +0200 Subject: [PATCH 023/155] 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 2532a8f8df4f..2145df271f58 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 90987b86ffa8..2b046bb92ea8 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 4df294745badcf1816bd5e750b6cc5d92a587830 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 15:57:59 +0200 Subject: [PATCH 024/155] 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 c6c8d62898e3..58b3d0bf94b0 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 d6a838f017f6..463282219674 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 6d69312eb4dfff72c32441c3c688df3cd840aaac Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:01:40 +0200 Subject: [PATCH 025/155] 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 58b3d0bf94b0..38b5d29785f3 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 463282219674..b1b3ed838687 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 aad7c0319df375b37e1ada461466b5bce41e328f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:04:01 +0200 Subject: [PATCH 026/155] 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 38b5d29785f3..6c348cdf0b20 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 b1b3ed838687..031fe5c832ad 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 2145df271f58..e75cf76769df 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 79bc3f0b51809c8102af68a802a44e1190c1db39 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:52:17 +0200 Subject: [PATCH 027/155] 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 db125de74100..61d764f814db 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 a7a9f89332f1..34fdf2caf670 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 b5161da106621b8c9e067b2a7147755b8aec6d5e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 8 Jun 2026 16:52:17 +0200 Subject: [PATCH 028/155] 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 6c348cdf0b20..db901cf5f07b 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 031fe5c832ad..d4c09a7ee940 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 0f12e672f66f..588e38d739ba 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 a0fb1a698da755709956033734da9ce795911ff9 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 00:53:15 +0200 Subject: [PATCH 029/155] 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 c3226884a31d..3be3a3255a6c 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 80f7d31cf2f9..88d30ce65a01 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 9baa0c1fa6a27dca026b0c80835b1d38269d7eba Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:01:54 +0200 Subject: [PATCH 030/155] 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 db901cf5f07b..d69118696abe 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 d4c09a7ee940..ee471f9c2476 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 806d96e1400ccec40bff6ae015bcc61f2892850f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:02:51 +0200 Subject: [PATCH 031/155] 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 d69118696abe..6e12e8b16739 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 ee471f9c2476..2f677608b39c 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 44037e0d25bb472ea8c210ae7befbc6c34db265e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:33 +0200 Subject: [PATCH 032/155] 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 ce464565dc5f..1edf4942c339 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 9c7a7c94bb10..da33f5689469 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 227c0d89b8c65328a7bb6f389f87aa1c7724298c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:49 +0200 Subject: [PATCH 033/155] 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 6e12e8b16739..08adf6a48f3c 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 2f677608b39c..39e7ace5e5f7 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 fc87f9dac8fb858812fbb713d9b8e92d959c14d6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 01:03:57 +0200 Subject: [PATCH 034/155] 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 39e7ace5e5f7..6277b6827232 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 2a013d93466724608cbbc313507119bd8f1bbdba Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 09:58:47 +0200 Subject: [PATCH 035/155] 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 08adf6a48f3c..e872ab993346 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 6277b6827232..3211daaa6bc1 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 5e55227a4552b313936d9db07c76718ccb306777 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 9 Jun 2026 10:50:34 +0200 Subject: [PATCH 036/155] 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 e872ab993346..4d0a98675a66 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 3211daaa6bc1..80b6b5e5537a 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 5d500030914b75cb485405dfb0b4184040fbff33 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 10 Jun 2026 08:49:00 +0200 Subject: [PATCH 037/155] 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 1edf4942c339..5ebc2e3a59ca 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 da33f5689469..be0d5358ff3c 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 130f82bdb3b8a6672cd2ccb93eb7624955c79a58 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:08:54 +0200 Subject: [PATCH 038/155] 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 d5c11e7698c2..05bbd60ac774 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 4d0a98675a66..5bba1d08edb5 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 80b6b5e5537a..7601249da343 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 24255ef2a70f..e203a7e230fa 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 588e38d739ba..4a1b5ae13b87 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 836bb3d8f76c2c11132d4da97d218fa57ce092bc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:47:04 +0200 Subject: [PATCH 039/155] 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 5bba1d08edb5..adb86b8bd923 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 7601249da343..e7dc1d1cf861 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 00b49345302f990058803c8f9b040dc2c017234c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 14:57:54 +0200 Subject: [PATCH 040/155] 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 adb86b8bd923..9decf70d4628 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 e7dc1d1cf861..b200360911a3 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 8d7d7f5130743eea122f1084d89df026d298f942 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:07:30 +0200 Subject: [PATCH 041/155] 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 9decf70d4628..643707746c20 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 b200360911a3..1d3c0afc577d 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 129f9b60c7bf998f36e650c3094a286943eab02b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:15:19 +0200 Subject: [PATCH 042/155] 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 643707746c20..b77c03941b35 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 1d3c0afc577d..6cbe84d4c610 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 1816021719dd6febcd095d9bcae02d89b76c4e25 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:20:11 +0200 Subject: [PATCH 043/155] 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 b77c03941b35..24347ffe61a0 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 6cbe84d4c610..5521f2b11e79 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 feab8d2b1ec9758f4834cf7cd4fc2b5dd7e08478 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 11 Jun 2026 15:23:43 +0200 Subject: [PATCH 044/155] 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 24347ffe61a0..a01c4451ddf1 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 5521f2b11e79..8f478d86bb14 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 aada078f9879afabd3d29b3f22bfd4fbece1eadf Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:20:46 +0200 Subject: [PATCH 045/155] 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 a01c4451ddf1..ca12a7fa179a 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 8f478d86bb14..2e4e706f1e28 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 9bd32b7d5658fc4461d6af4152523524fe48d11a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:22:14 +0200 Subject: [PATCH 046/155] 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 ca12a7fa179a..78684d9e693d 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 2e4e706f1e28..18b67f6f88ea 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 dfe699b80dbea4f648f5ce08beeefaf5c78abe15 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:23:21 +0200 Subject: [PATCH 047/155] 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 78684d9e693d..31cddd9cf717 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 18b67f6f88ea..104308ea84bf 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 55a21a4cb47ca1b1d56cb31f83d0e54f986da0da Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:24:55 +0200 Subject: [PATCH 048/155] 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 31cddd9cf717..1fcb79cdfaa6 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 104308ea84bf..b7a4e752e785 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 d0ac4cc04a2b7a9fa7ca9cd6ff34c713ba6cd598 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:26:57 +0200 Subject: [PATCH 049/155] 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 b7a4e752e785..b006c9542617 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 9c45753cd8ed..bc2a159aa867 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -71,6 +71,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 000000000000..b2cd407601ed --- /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 000000000000..d41457548920 --- /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 fa6b682414af6cdc3d63402e030ca72ecbd7f01d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:28:15 +0200 Subject: [PATCH 050/155] 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 1fcb79cdfaa6..86e894b31403 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 b006c9542617..d88b8f7ab007 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 60d8c5c749244d3321aafc0f8e643fe40717229a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:29:25 +0200 Subject: [PATCH 051/155] 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 001ba3889496..1bcf64d6f4b0 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 3e5978f4846d..69f420f5304d 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 f72bc79f39453c738072bf673c322e6280cbc452 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:30:13 +0200 Subject: [PATCH 052/155] 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 1bcf64d6f4b0..9f84c4ec2414 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 69f420f5304d..eb177065bb79 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 3b12f67191a0801a86f8c1603850b78a6a79129a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 12 Jun 2026 11:31:05 +0200 Subject: [PATCH 053/155] 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 86e894b31403..f54520701417 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 d88b8f7ab007..41c3c05668d1 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 b2cd407601ed..b9bc36c8593c 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 d01f19dd016a3c79a2adf8f43601950233c9db25 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 13 Jun 2026 22:06:10 +0200 Subject: [PATCH 054/155] 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 f54520701417..ff53e4d2b35d 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 41c3c05668d1..479eca60bbd2 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 768dcf8bd5635b63b1ac97965d770bc26426e55f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 08:48:21 +0200 Subject: [PATCH 055/155] 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 ff53e4d2b35d..8544ab53c39c 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 479eca60bbd2..7c5104a7fd8a 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 d4207564a1a3c95fdd04aba97a8cc54ca4bd8a1c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:05:07 +0200 Subject: [PATCH 056/155] 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 f12df850941f..debc4b45c74c 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 8544ab53c39c..1d2ee54f7f91 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 7c5104a7fd8a..ec1fa6c56101 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 94eb1c65370e..7dd96d48e3c3 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 670540fee701..ee6d3ffd2780 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 5917005bea4016160a996211d2612c0568b86c3d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:52:50 +0200 Subject: [PATCH 057/155] 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 707c87c43c99..a2ab13465780 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 3ba244ddf2c4..23a91eaaf8f3 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 61905f788aa30abe995ee3703e63404ec4025ad5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 09:55:08 +0200 Subject: [PATCH 058/155] 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 5ebc2e3a59ca..63806798d933 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 be0d5358ff3c..0373a8694ae1 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 9ac833d2872cd02dcdfe43269839353290dd814a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 10:15:30 +0200 Subject: [PATCH 059/155] 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 1d2ee54f7f91..dc63d994d60c 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 ec1fa6c56101..c8e945588011 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 53b6df86c86a81da055c101782ecfedea066788c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 11:22:01 +0200 Subject: [PATCH 060/155] 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 dc63d994d60c..39f3ee87bdce 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 c8e945588011..72b514755d94 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 64522833bc81a58af03141985c2f4c77c56b15c0 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:11:35 +0200 Subject: [PATCH 061/155] 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 39f3ee87bdce..ae0bb61b6e20 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 72b514755d94..889053c80a04 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 e2aab9615feccfedb42c657c0880acbb72dfe3a8 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:48:25 +0200 Subject: [PATCH 062/155] 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 889053c80a04..4007aa4c4ee0 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 d2cb1b06d4771836f6d7ba84550605b01e6ea94a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:55:25 +0200 Subject: [PATCH 063/155] 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 b9bc36c8593c..fabc4bd33224 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 d41457548920..c1052b406f28 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 4e87af8e14236d9d45c2ad02ff042c7c03bb584a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 12:55:25 +0200 Subject: [PATCH 064/155] 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 a2ab13465780..839fecb1142b 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 23a91eaaf8f3..7b322d09c2a6 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 3616cb70cd2f3820689138fdeef0d02241f9a2d8 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 13:02:32 +0200 Subject: [PATCH 065/155] 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 fabc4bd33224..05bf10fd1e4f 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 c1052b406f28..b72de311735d 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 a801f4713d41760299a7ea1a84f929c35ef53e3d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 14 Jun 2026 13:03:17 +0200 Subject: [PATCH 066/155] 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 839fecb1142b..486da21c73cf 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 7b322d09c2a6..7d7e81ac0b3a 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 e9bd5731f34692ea6e3dc269d19ccda85676d8a7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 03:33:01 +0200 Subject: [PATCH 067/155] 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 ae0bb61b6e20..63cde32874c0 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 4007aa4c4ee0..400d71efdb1e 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 e75cf76769df..3f9925f55ae5 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 61d764f814db..21f5a02c5d64 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 a5f7ecddb271..9ba1ed5ec3f2 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 45e888203aa88748a36ab90d9502c054d26be56c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 03:51:44 +0200 Subject: [PATCH 068/155] 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 63cde32874c0..5367c2639fdd 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 400d71efdb1e..a87242c1ef8f 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 3f9925f55ae5..b464e5d2b070 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 2b046bb92ea8..050925a9eaa5 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 21f5a02c5d64..03e31366ad95 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 34fdf2caf670..1f7db737085b 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 9ba1ed5ec3f2..7b9902cee8a4 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 4ed96c0b80ed..5c13e5a68bf0 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 228c9055f814b5c6196c01a6bd8b01e289b87fce Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:19:29 +0200 Subject: [PATCH 069/155] 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 5367c2639fdd..54feee3d5525 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 a87242c1ef8f..e25062df0876 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 1f7db737085b..85ddeafaa1c6 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 285910cd916eef3ab3ba1ad9076345ea813e21cc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:41:49 +0200 Subject: [PATCH 070/155] 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 54feee3d5525..344f9bb8a0b7 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 b464e5d2b070..f0e10fed280c 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 7b9902cee8a4..85d6183adc12 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 5e061d6097d35dd86a98341d751bfc5198eaaa3c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 15 Jun 2026 04:56:47 +0200 Subject: [PATCH 071/155] 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 344f9bb8a0b7..f9381d42f545 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 f0e10fed280c..999937404700 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 85d6183adc12..3e6fc1bc41c7 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 ba2414e8cd9086b7306026ea58f7294993d2f264 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 10:48:08 +0200 Subject: [PATCH 072/155] 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 ac2d47ca3365..6a38be777ce5 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 9436e251f837..90750b0a577b 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 47015f1a20a0e25d80b1fd19d135de4d0ad9de8a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 12:17:26 +0200 Subject: [PATCH 073/155] 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 debc4b45c74c..f12df850941f 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 0373a8694ae1..65b95257bf3a 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 f9381d42f545..effd37635914 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 e25062df0876..6e0c3b3c5450 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 7dd96d48e3c3..94eb1c65370e 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 ee6d3ffd2780..ee290701885c 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 f32d65dd2de4691bfd2bf8e66af002ef1f6849c3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 16 Jun 2026 13:46:34 +0200 Subject: [PATCH 074/155] 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 73bbba188ba4..44d7f45ec10d 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 c1b0e71f8366..7a9f09ad4101 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 e34a5881743b3a004c79fbb9fc4fc32223ac32b6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:23:03 +0200 Subject: [PATCH 075/155] 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 ccfb9c467421..acd04ecf184a 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 f12df850941f..814c47125ba2 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 9f84c4ec2414..bcc4eed1ba3e 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 eb177065bb79..51c0a7867682 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 3b47f19ff613225bfe6b333220b13367f8bd7b46 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:23:21 +0200 Subject: [PATCH 076/155] 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 effd37635914..872e44ee5bf4 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 6e0c3b3c5450..bf9671fb244a 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 000000000000..fb190b8c95ab --- /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 b72de311735d..f44d9b995c48 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 bc2a159aa867..9c45753cd8ed 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -71,10 +71,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 05bf10fd1e4f..000000000000 --- 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 5ff2184e070961c483f416076ff8547ed77ad1c0 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:28:18 +0200 Subject: [PATCH 077/155] 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 872e44ee5bf4..77463d7d6995 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 bf9671fb244a..be3a598a4e6c 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 5b15ff8a297c0f279ea36281622fae94bc88a344 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 03:45:07 +0200 Subject: [PATCH 078/155] 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 77463d7d6995..ff7f2c0505a4 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 0c0ae9b378813a40b8c8cc1f1746d391e77101a4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:31:00 +0200 Subject: [PATCH 079/155] 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 be3a598a4e6c..46916964b4c7 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 000000000000..b4ade96d2f02 --- /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 f44d9b995c48..5d2d1d18e4b8 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 0e68994fd3dd..abb1c57b6f76 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 5c13e5a68bf0..9614da7c014e 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 ad2911c20f76..563136032aa9 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 90f20ab9ede6e7e7c8191be9e1c0a2d2d21a05bf Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:31:32 +0200 Subject: [PATCH 080/155] 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 6a672785acbe..c6196f3efa5b 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 6397708f68e279740b67708e771fbc19a0d456b5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 18 Jun 2026 04:43:27 +0200 Subject: [PATCH 081/155] 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 46916964b4c7..f4cb19392b75 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 eae8db75e54991de15c648553ef5ef54d3a5c972 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 03:30:30 +0200 Subject: [PATCH 082/155] 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 bcc4eed1ba3e..829d275a741f 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 51c0a7867682..5471f6ec9668 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 ff7f2c0505a4..532afb8b4e82 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 f4cb19392b75..21b57661925f 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 c5d911c2bbd7c29821505fbfbe3051d6271f76c6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 03:59:06 +0200 Subject: [PATCH 083/155] 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 917c92bf7d56..1b527825128f 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 829d275a741f..0b8a153f30eb 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 5471f6ec9668..62ba4ce49824 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 629c947afbd1..3a73de206f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,7 +454,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 7626c62d82727802f603a778daffacb17077e35c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 08:53:30 +0200 Subject: [PATCH 084/155] 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 0b8a153f30eb..cb508afecdaf 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 62ba4ce49824..b098fcb3f956 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 9f0d5cd7f412bb8091081d91248786a09177ff1c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 19 Jun 2026 08:53:56 +0200 Subject: [PATCH 085/155] 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 532afb8b4e82..0f714b82301e 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 21b57661925f..6b56cda8f329 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 c169d87a15df2cf26e6b157f7a7c8b382d568273 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:12:29 +0200 Subject: [PATCH 086/155] 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 486da21c73cf..817993e3604e 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 0f714b82301e..b329fe4d1cc9 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 6b56cda8f329..1a355e79078b 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 999937404700..22c153814d34 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 fb190b8c95ab..a60d30e327db 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 03e31366ad95..5bc7df270eb3 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 85ddeafaa1c6..d763e5dbf1be 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 44d7f45ec10d..8cfb1c3ae9dd 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 1a1cc8ab8307c02257c13e28f2aa4e0a8765898e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:23:23 +0200 Subject: [PATCH 087/155] 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 63806798d933..f72e5089aa84 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 65b95257bf3a..ba8f84cba648 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 4706da29374246da691262f440a2faee966be107 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 16:49:59 +0200 Subject: [PATCH 088/155] 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 f72e5089aa84..b104afccb88d 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 ba8f84cba648..5deaba24d332 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 cb508afecdaf..616eced0d378 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 b098fcb3f956..5b65ccbb4fc4 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 05bbd60ac774..01a322e3914d 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 b329fe4d1cc9..50058f6b657c 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 1a355e79078b..406c7ba699cd 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 a60d30e327db..697358857f58 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 5d2d1d18e4b8..1a41d5e67334 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 5bc7df270eb3..074450c90bd8 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 d763e5dbf1be..8160b9b48678 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 9614da7c014e..4b106d543fda 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 dfdc13d7be641f5485bcdbf3617333ca6aa6bc4f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 17:18:47 +0200 Subject: [PATCH 089/155] 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 074450c90bd8..0053e4deb9ee 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 8160b9b48678..ea12d3fe0b7e 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 4b106d543fda..bf690f2ede54 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 543123b4ae76a40286dcd27718b0baf832441a9b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 17:32:03 +0200 Subject: [PATCH 090/155] 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 01a322e3914d..a98a68ab3701 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 406c7ba699cd..dd4d3376b724 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 050925a9eaa5..753b8a1b5a54 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 abb1c57b6f76..1f99ec75ef7a 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 bf690f2ede54..ebba0167143f 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 00c5f1f84c6ec321c9fb48551e2357bd7c9f4efa Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 21:45:49 +0200 Subject: [PATCH 091/155] 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 0053e4deb9ee..d1c458beccfd 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 ea12d3fe0b7e..bd53a87052c7 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 44c11a74941384d0abe536cecaf1249ce5ed756e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 22:10:38 +0200 Subject: [PATCH 092/155] 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 d1c458beccfd..955933cdda89 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 bd53a87052c7..1df56b45715e 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 a9aa6f4ea440687b61f603a2a44b21a1a7d50f85 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 30 Jun 2026 23:23:29 +0200 Subject: [PATCH 093/155] 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 dd4d3376b724..f45eef6bc742 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 955933cdda89..75f71dc5a80c 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 1df56b45715e..f7e686c4166a 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 ebba0167143f..e0b5476ed377 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 718c6bac7d183d78b40381658908d1e786dd4c7a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:15:55 +0200 Subject: [PATCH 094/155] 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 3e6fc1bc41c7..8fd706ee2bf0 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 e0b5476ed377..88494ec60af5 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 229b438a7adbb6c1fd28681f03205a1d9d681b1d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:20:26 +0200 Subject: [PATCH 095/155] 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 b104afccb88d..a3860fbf7e63 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 5deaba24d332..51d1966a732c 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 8fb57e2868c612533070e0b3dd6cbb986d30be65 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 1 Jul 2026 09:33:12 +0200 Subject: [PATCH 096/155] 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 a3860fbf7e63..b104afccb88d 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 51d1966a732c..5deaba24d332 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 8fd706ee2bf0..31d713946b18 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 88494ec60af5..f920f63500b2 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 1f207c222832864559ca7bee39df28db45acad18 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 12:06:07 +0200 Subject: [PATCH 097/155] 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 50058f6b657c..57a419e78e3a 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 f45eef6bc742..363bb5f8b642 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 30c866a904c44332482696291616907514206e16 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 13:00:51 +0200 Subject: [PATCH 098/155] 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 363bb5f8b642..71c9717c115a 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 f7e686c4166a..526ea9f6dc39 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 f920f63500b2..afb85e0b0efa 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 3efc4177736ae44928f791de0d0ef62a61f775c1 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Fri, 3 Jul 2026 13:19:37 +0200 Subject: [PATCH 099/155] 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 afb85e0b0efa..aa9c22cb0c07 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 f6fcc44aa35e1a4d2cc15d45cd902835b55915e2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 13:06:07 +0200 Subject: [PATCH 100/155] 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 | 84 +++- packages/shared/src/agentAwareness.ts | 3 + pnpm-lock.yaml | 111 +++++- 9 files changed, 595 insertions(+), 216 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 1b527825128f..6a39418dda6b 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 616eced0d378..951b31cc93d7 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 5b65ccbb4fc4..d4e3b902ec7e 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 57a419e78e3a..f8a12554e125 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 71c9717c115a..c91ac31d6ff1 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 aa9c22cb0c07..afb85e0b0efa 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 28e07c04e6f3..8cb066594948 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -135,23 +135,101 @@ describe("projectThreadAwareness", () => { // Quick threads without code changes never get a checkpoint, so the SQL // shell has no latestTurn row and latest_turn_id is cleared when the // session settles; the ready session is the only completion signal left. + + 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: "ready", + status: "running", providerName: "Codex", runtimeMode: "full-access", - activeTurnId: null, + activeTurnId: "turn-1" as TurnId, lastError: null, - updatedAt: NOW, + updatedAt: "2026-05-22T12:00:00.000Z", + }, + latestTurn: { + state: "running", + requestedAt: "2026-05-22T12:00:00.000Z", + startedAt: "2026-05-22T12:00:00.000Z", + completedAt: null, +>>>>>>> 0a10280c8 (refine copilot lifecycle and remove stale awareness fallback) + 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, }, }), }); + }), + +<<<<<<< HEAD expect(state?.phase).toBe("completed"); +======= + 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"); +>>>>>>> 0a10280c8 (refine copilot lifecycle and remove stale awareness fallback) }); it("projects failures with the session error detail", () => { diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index c0f5842eb7c5..6070b5e835d6 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -148,5 +148,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 3a73de206f3a..5cfc7b6820f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,8 +460,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 @@ -2708,39 +2708,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==} @@ -2748,16 +2792,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'} @@ -12767,33 +12827,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 @@ -12807,6 +12891,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 56d5b3cca3afdf6425723704379186770695a155 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:07:06 +0200 Subject: [PATCH 101/155] 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 b104afccb88d..af97c78e83d2 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 5deaba24d332..a76bddf0ccd0 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 d4e3b902ec7e..0aaee21aa339 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 f8a12554e125..e77c5637eddd 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 c91ac31d6ff1..8b773ab1225f 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 d3238afd2b2bef5edd7a08aba742ee6d8563c807 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:24:20 +0200 Subject: [PATCH 102/155] 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 0aaee21aa339..a838d9463a2d 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 e77c5637eddd..56b36d288f19 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 8b773ab1225f..f911a7118272 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 fd17f3fd64cfe8ab0d65ff28f266a8848fa49396 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 6 Jul 2026 14:47:15 +0200 Subject: [PATCH 103/155] 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 56b36d288f19..aa66205c308c 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 526ea9f6dc39..c4e42d52a2c2 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/155] 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 c4e42d52a2c2..090a6894486b 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); From 80d2638a674db014af68af4aa86218ee6affde8e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 10:47:31 +0200 Subject: [PATCH 105/155] fix leftover merge markers in agent awareness test --- packages/shared/src/agentAwareness.test.ts | 67 ++++------------------ 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 8cb066594948..655567297d8c 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -135,41 +135,31 @@ describe("projectThreadAwareness", () => { // Quick threads without code changes never get a checkpoint, so the SQL // shell has no latestTurn row and latest_turn_id is cleared when the // session settles; the ready session is the only completion signal left. - - 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", + status: "ready", providerName: "Codex", runtimeMode: "full-access", - activeTurnId: "turn-1" as TurnId, + activeTurnId: null, lastError: null, - updatedAt: "2026-05-22T12:00:00.000Z", + updatedAt: NOW, }, - latestTurn: { - state: "running", - requestedAt: "2026-05-22T12:00:00.000Z", - startedAt: "2026-05-22T12:00:00.000Z", - completedAt: null, ->>>>>>> 0a10280c8 (refine copilot lifecycle and remove stale awareness fallback) - expect(state).toMatchObject({ - phase: "running", - headline: "Agent is working", - detail: "Codex is active.", + }), }); + + expect(state?.phase).toBe("completed"); }); - it("keeps recently updated running threads in running phase", () => { + 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", + updatedAt: NOW, session: { threadId: "thread-1" as ThreadId, status: "running", @@ -177,24 +167,19 @@ describe("projectThreadAwareness", () => { runtimeMode: "full-access", activeTurnId: "turn-1" as TurnId, lastError: null, - updatedAt: "2026-05-22T12:00:00.000Z", + updatedAt: NOW, }, latestTurn: { turnId: "turn-1" as TurnId, state: "running", - requestedAt: "2026-05-22T12:00:00.000Z", - startedAt: "2026-05-22T12:00:00.000Z", + requestedAt: NOW, + startedAt: NOW, completedAt: null, assistantMessageId: null, }, }), }); - }), - -<<<<<<< HEAD - expect(state?.phase).toBe("completed"); -======= expect(state).toMatchObject({ phase: "running", headline: "Agent is working", @@ -202,36 +187,6 @@ describe("projectThreadAwareness", () => { }); }); - 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"); ->>>>>>> 0a10280c8 (refine copilot lifecycle and remove stale awareness fallback) - }); - it("projects failures with the session error detail", () => { const state = projectThreadAwareness({ environmentId: "env-1" as EnvironmentId, From 3e96238ad98f48b4d572ddf99261aeb42e2127e2 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 11:20:47 +0200 Subject: [PATCH 106/155] Fix 8 code review issues on Copilot provider PR - Fix ProviderCommandReactor: reorder branch rename before title generation - Fix CopilotAdapter: change backgroundTasks to tasks for SDK API - Fix copilotRuntime: search bundled CLI in module dir before cwd - Fix CopilotTextGeneration: prevent concurrent client race with pending map - Fix CopilotAdapter: add unique handlerIds for ask_user correlation - Fix ProviderRuntimeIngestion: preserve whitespace-only deltas - Fix CopilotAdapter: delete permission binding before deferred resolution - Fix CopilotProvider: add 30s timeout to SDK probe --- .../Layers/ProviderCommandReactor.ts | 18 +++--- .../Layers/ProviderRuntimeIngestion.ts | 2 +- .../src/provider/Layers/CopilotAdapter.ts | 10 +-- .../src/provider/Layers/CopilotProvider.ts | 5 +- apps/server/src/provider/copilotRuntime.ts | 7 +-- .../textGeneration/CopilotTextGeneration.ts | 61 +++++++++++-------- 6 files changed, 60 insertions(+), 43 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a76bddf0ccd0..d66c352899a8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -821,6 +821,15 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; + yield* firstTurnAuxiliaryWorker.enqueue( + maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }), + ); + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed, message.text)) { yield* firstTurnAuxiliaryWorker.enqueue( maybeGenerateThreadTitleForFirstTurn({ @@ -830,15 +839,6 @@ const make = Effect.gen(function* () { }), ); } - - yield* firstTurnAuxiliaryWorker.enqueue( - maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }), - ); } const handleTurnStartFailure = (cause: Cause.Cause) => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a838d9463a2d..ca0a8f8d4373 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -555,7 +555,7 @@ function runtimeEventToActivities( } case "content.delta": { - if (event.payload.delta.trim().length === 0) { + if (event.payload.delta.length === 0) { return []; } if ( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index f911a7118272..77f22fabf381 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -141,6 +141,7 @@ interface PendingPermissionHandler { } interface PendingUserInputHandler { + readonly handlerId: string; readonly signature: string; readonly deferred: Deferred.Deferred; } @@ -694,8 +695,7 @@ function completedToolDiffText( function copilotBackgroundTasksList( session: CopilotSession, ): (() => Promise) | undefined { - const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks - ?.list; + const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).tasks?.list; return list; } @@ -1052,13 +1052,13 @@ function settlePendingPermissionHandlers( context.pendingPermissionHandlersBySignature.clear(); context.pendingPermissionEventsBySignature.clear(); - for (const binding of context.pendingPermissionBindings.values()) { + for (const [key, binding] of context.pendingPermissionBindings.entries()) { + context.pendingPermissionBindings.delete(key); yield* Deferred.succeed(binding.deferred, DENIED_PERMISSION_RESULT).pipe(Effect.ignore); if (onBindingSettled) { yield* onBindingSettled(binding).pipe(Effect.ignore); } } - context.pendingPermissionBindings.clear(); }); } @@ -2023,9 +2023,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const signature = userInputSignature(request); + const handlerId = NodeCrypto.randomUUID(); const deferred = yield* Deferred.make(); const queue = context.pendingUserInputHandlersBySignature.get(signature) ?? []; queue.push({ + handlerId, signature, deferred, }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index 753b8a1b5a54..a4c9b2bd7738 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 * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; @@ -148,7 +149,9 @@ export function checkCopilotProviderStatus(input: { }); }, catch: toCopilotProbeError, - }).pipe(Effect.catch((cause) => Effect.succeed(fallback(cause)))), + }) + .pipe(Effect.timeout(Duration.seconds(30))) + .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.ts b/apps/server/src/provider/copilotRuntime.ts index 090a6894486b..41047d1d4045 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -340,15 +340,14 @@ export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliP const moduleDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const candidateRoots = new Set(); + for (const directory of candidateDirectoryAncestors(moduleDirectory)) { + candidateRoots.add(directory); + } if (input.cwd) { candidateRoots.add(input.cwd); } candidateRoots.add(process.cwd()); - for (const directory of candidateDirectoryAncestors(moduleDirectory)) { - candidateRoots.add(directory); - } - for (const root of candidateRoots) { const candidate = NodePath.join(root, "node_modules", ".bin", COPILOT_CLI_COMMAND); const resolved = yield* resolveCopilotCommandPath(candidate, { diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index afb85e0b0efa..e7a31f3345fd 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -1,4 +1,5 @@ import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -113,6 +114,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ); const sharedClientMutex = yield* Semaphore.make(1); const sharedClients = new Map(); + const pendingClients = new Map>(); const closeSharedClient = (clientKey: string) => Effect.gen(function* () { @@ -176,21 +178,39 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( cwd: input.cwd, }); - const existingClient = yield* sharedClientMutex.withPermit( - Effect.gen(function* () { - const existing = sharedClients.get(clientKey); - if (existing) { + // First check if a client is already started + const existing = sharedClients.get(clientKey); + if (existing) { + yield* sharedClientMutex.withPermit( + Effect.gen(function* () { yield* cancelIdleCloseFiber(existing); existing.activeRequests += 1; - return existing.client; - } - return undefined; - }), - ); - if (existingClient) { - return { clientKey, client: existingClient }; + }), + ); + return { clientKey, client: existing.client }; + } + + // Check if a client is currently being started (pending) + const pendingDeferred = pendingClients.get(clientKey); + if (pendingDeferred) { + const client = yield* Deferred.await(pendingDeferred); + yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const state = sharedClients.get(clientKey); + if (state) { + yield* cancelIdleCloseFiber(state); + state.activeRequests += 1; + } + }), + ); + return { clientKey, client }; } + // Create a deferred for this client startup + const startupDeferred = yield* Deferred.make(); + pendingClients.set(clientKey, startupDeferred); + + // Start the client const newClient = yield* createCopilotClient({ settings: input.settings, cwd: input.cwd, @@ -209,6 +229,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }), ), ); + const client = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { yield* restore( @@ -240,27 +261,19 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }), ); - return yield* sharedClientMutex.withPermit( + 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; - } - + pendingClients.delete(clientKey); sharedClients.set(clientKey, { client: newClient, activeRequests: 1, idleCloseFiber: null, }); - return newClient; + yield* Deferred.succeed(startupDeferred, newClient); }), ); + + return newClient; }), ); return { clientKey, client }; From ed987014279c6466e79434567e1c8684b7163bbc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 11:32:15 +0200 Subject: [PATCH 107/155] fix(copilot): fail startupDeferred on client init error and guard sharedClients lookup under mutex - Move sharedClients.get(clientKey) inside the semaphore permit so the lookup and activeRequests increment are atomic, preventing a race where the idle-close fiber removes the entry between the outer read and the lock acquisition (stale/stopped client would be returned). - Wrap the createCopilotClient + .start() block in a sub-effect with tapError that fails startupDeferred and removes it from pendingClients on any error, so concurrent waiters are unblocked instead of hanging forever on an unresolvable Deferred. Addresses code review comments: - 'Pending client startup never fails' (High) - 'Shared client lookup race' (Medium) --- .../textGeneration/CopilotTextGeneration.ts | 161 ++++++++++-------- 1 file changed, 88 insertions(+), 73 deletions(-) diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index e7a31f3345fd..c11a040d9a1e 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -178,16 +178,20 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( cwd: input.cwd, }); - // First check if a client is already started - const existing = sharedClients.get(clientKey); - if (existing) { - yield* sharedClientMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(existing); - existing.activeRequests += 1; - }), - ); - return { clientKey, client: existing.client }; + // First check if a client is already started — done inside the mutex so the + // lookup and activeRequests increment are atomic and cannot race with the + // idle-close fiber removing the entry. + const existingClient = yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + const existing = sharedClients.get(clientKey); + if (!existing) return null; + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return existing.client; + }), + ); + if (existingClient !== null) { + return { clientKey, client: existingClient }; } // Check if a client is currently being started (pending) @@ -210,71 +214,82 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( const startupDeferred = yield* Deferred.make(); pendingClients.set(clientKey, startupDeferred); - // Start the client - const newClient = yield* createCopilotClient({ - settings: input.settings, - cwd: input.cwd, - binaryPathBaseDirectory: serverConfig.cwd, - ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), - env: environment, - platform, - logLevel: "error", - }).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: detailFromCause(cause, "Failed to configure Copilot client."), - cause, - }), - ), - ); - - const client = yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - yield* restore( - Effect.tryPromise({ - try: (signal) => - new Promise((resolve, reject) => { - const abort = () => { - void newClient.stop().catch(() => undefined); - reject(signal.reason ?? new Error("Copilot client startup interrupted.")); - }; - if (signal.aborted) { - abort(); - return; - } - signal.addEventListener("abort", abort, { once: true }); - newClient - .start() - .then(resolve, reject) - .finally(() => { - signal.removeEventListener("abort", abort); - }); - }), - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: detailFromCause(cause, "Failed to start Copilot client."), - cause, - }), - }), - ); + // Start the client. If any step fails we must fail and remove + // startupDeferred so pending waiters are unblocked rather than + // hanging forever. + const client = yield* Effect.gen(function* () { + const newClient = yield* createCopilotClient({ + settings: input.settings, + cwd: input.cwd, + binaryPathBaseDirectory: serverConfig.cwd, + ...(options?.baseDirectory ? { baseDirectory: options.baseDirectory } : {}), + env: environment, + platform, + logLevel: "error", + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to configure Copilot client."), + cause, + }), + ), + ); - yield* sharedClientMutex.withPermit( - Effect.gen(function* () { - pendingClients.delete(clientKey); - sharedClients.set(clientKey, { - client: newClient, - activeRequests: 1, - idleCloseFiber: null, - }); - yield* Deferred.succeed(startupDeferred, newClient); - }), - ); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore( + Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + const abort = () => { + void newClient.stop().catch(() => undefined); + reject(signal.reason ?? new Error("Copilot client startup interrupted.")); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); + newClient + .start() + .then(resolve, reject) + .finally(() => { + signal.removeEventListener("abort", abort); + }); + }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to start Copilot client."), + cause, + }), + }), + ); + + yield* sharedClientMutex.withPermit( + Effect.gen(function* () { + pendingClients.delete(clientKey); + sharedClients.set(clientKey, { + client: newClient, + activeRequests: 1, + idleCloseFiber: null, + }); + yield* Deferred.succeed(startupDeferred, newClient); + }), + ); + }), + ); - return newClient; - }), + return newClient; + }).pipe( + Effect.tapError((error) => + Effect.gen(function* () { + pendingClients.delete(clientKey); + yield* Deferred.fail(startupDeferred, error); + }), + ), ); return { clientKey, client }; }); From c370fe2da178a150208eb260f8df80460ff2aa3d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 11:53:46 +0200 Subject: [PATCH 108/155] fix(copilot): fix backgroundTasks RPC property name and contextTier formatting - Fix copilotBackgroundTasksList accessing rpc.tasks?.list instead of rpc.backgroundTasks?.list, causing background task events to never be dispatched and two tests to fail - Restore contextTier type cast to 3-line form to fix Linux CI formatter incompatibility (macOS oxfmt v0.57.0 allows 101-char lines while Linux oxfmt correctly enforces the 100-char limit) --- apps/server/src/provider/Layers/CopilotAdapter.ts | 3 ++- apps/server/src/textGeneration/CopilotTextGeneration.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 77f22fabf381..6bb2a35eb825 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -695,7 +695,8 @@ function completedToolDiffText( function copilotBackgroundTasksList( session: CopilotSession, ): (() => Promise) | undefined { - const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).tasks?.list; + const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks + ?.list; return list; } diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index c11a040d9a1e..808d7897fb3a 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -352,9 +352,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 169fea4f60bdd3f31288b6986026cc0eb9fa6e59 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 12:25:09 +0200 Subject: [PATCH 109/155] Fix Copilot background task listing --- .../provider/Layers/CopilotAdapter.test.ts | 20 ++++++++++++++----- .../src/provider/Layers/CopilotAdapter.ts | 18 +++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index aa66205c308c..0e3d05688b86 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -43,6 +43,13 @@ const waitForSdkEventQueue = () => const runtimeMock = vi.hoisted(() => { const makeSession = () => ({ + tasks: { + list: vi.fn( + async (): Promise<{ tasks: Array> }> => ({ + tasks: [], + }), + ), + }, sessionId: "copilot-sdk-session-1", rpc: { mode: { @@ -2614,7 +2621,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -2737,7 +2744,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; - runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -2772,7 +2779,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); } - runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -2935,7 +2942,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.rpc.backgroundTasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ tasks: [], }); @@ -3007,8 +3014,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); const session = runtimeMock.state.lastSession as unknown as { - rpc: { backgroundTasks?: unknown }; + tasks?: unknown; + rpc: { tasks?: unknown; backgroundTasks?: unknown }; }; + delete session.tasks; + delete session.rpc.tasks; delete session.rpc.backgroundTasks; const config = runtimeMock.state.createSessionConfigs.at(-1); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6bb2a35eb825..8fe8258bc320 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -105,10 +105,17 @@ type CopilotTaskInfo = { type CopilotTaskList = { readonly tasks: ReadonlyArray; }; +type CopilotSessionTasks = { + readonly list?: () => Promise; +}; +type CopilotTasksAccessor = { + readonly tasks?: CopilotSessionTasks; +}; type CopilotBackgroundTasksRpc = { readonly backgroundTasks?: { readonly list?: () => Promise; }; + readonly tasks?: CopilotSessionTasks; }; type PlanStep = { @@ -695,9 +702,12 @@ function completedToolDiffText( function copilotBackgroundTasksList( session: CopilotSession, ): (() => Promise) | undefined { - const list = (session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc).backgroundTasks - ?.list; - return list; + const sessionTasks = (session as CopilotSession & CopilotTasksAccessor).tasks?.list; + if (sessionTasks) { + return sessionTasks; + } + const rpc = session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc; + return rpc.tasks?.list ?? rpc.backgroundTasks?.list; } function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status"] { @@ -1339,7 +1349,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( catch: (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, - method: "session.backgroundTasks.list", + method: "session.tasks.list", detail: detailFromCause(cause, "Failed to read Copilot background tasks."), cause, }), From d194218ab998cf94845b50cf28e3b19d522a34e5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 12:34:34 +0200 Subject: [PATCH 110/155] Address Copilot Effect review comments --- apps/server/src/provider/copilotRuntime.ts | 19 +++++++------- .../textGeneration/CopilotTextGeneration.ts | 26 +++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 41047d1d4045..29bfeaa3156f 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -303,14 +303,15 @@ const validateConfiguredCopilotCliPath = Effect.fn("validateConfiguredCopilotCli platform: input.platform, ...(input.binaryPathBaseDirectory ? { cwd: input.binaryPathBaseDirectory } : {}), }).pipe( - Effect.catchTag("CommandResolutionError", () => - Effect.fail( - new CopilotCliPathResolutionError({ - detail: "The configured Copilot binary could not be found.", - binaryPath: cliPath, - }), - ), - ), + Effect.catchTags({ + CommandResolutionError: () => + Effect.fail( + new CopilotCliPathResolutionError({ + detail: "The configured Copilot binary could not be found.", + binaryPath: cliPath, + }), + ), + }), ); }, ); @@ -353,7 +354,7 @@ export const resolveBundledCopilotCliPath = Effect.fn("resolveBundledCopilotCliP const resolved = yield* resolveCopilotCommandPath(candidate, { env: input.env ?? process.env, platform: input.platform, - }).pipe(Effect.catchTag("CommandResolutionError", () => Effect.void)); + }).pipe(Effect.catchTags({ CommandResolutionError: () => Effect.void })); if (resolved) { return resolved; } diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 808d7897fb3a..45d7135d6139 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -352,10 +352,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. @@ -426,15 +425,16 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawContent)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "Copilot returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Copilot returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => From 7cc94f6db75a62f862f22ec957e9df95370e3168 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 12:45:16 +0200 Subject: [PATCH 111/155] Fix Copilot text generation formatting --- apps/server/src/textGeneration/CopilotTextGeneration.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 45d7135d6139..b1b4f91cf59b 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -352,9 +352,8 @@ 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"); + const copilotContextTier = 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. @@ -372,7 +371,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( clientName: "t3-code-git-text", model: input.modelSelection.model, ...(reasoningEffort ? { reasoningEffort } : {}), - ...(contextTier ? { contextTier } : {}), + ...(copilotContextTier ? { contextTier: copilotContextTier } : {}), workingDirectory: input.cwd, streaming: false, availableTools: [], From 903b8c2aef3a23e2f2868b775c3f86308e851966 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 7 Jul 2026 12:55:13 +0200 Subject: [PATCH 112/155] Fix Copilot test Effect imports --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 8 +++++++- apps/server/src/provider/Layers/CopilotProvider.test.ts | 4 +++- .../src/textGeneration/CopilotTextGeneration.test.ts | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 0e3d05688b86..dbcef6874876 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -10,7 +10,13 @@ import type { SessionEvent, } from "@github/copilot-sdk"; import { beforeEach, it } from "@effect/vitest"; -import { Context, DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 22c153814d34..e8638b91ecef 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -2,7 +2,9 @@ import * as NodeAssert from "node:assert/strict"; import { beforeEach, describe, it } from "@effect/vitest"; import { CopilotSettings } from "@t3tools/contracts"; -import { DateTime, Effect, Schema } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; 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 31d713946b18..ed48ab100d3a 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 } from "@effect/vitest"; -import { Effect, Layer } from "effect"; import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; import { ServerConfig } from "../config.ts"; From 46e29660899567e8ce2ff2a17abe131075b66215 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 12 Jul 2026 02:49:35 +0200 Subject: [PATCH 113/155] Fix Copilot review findings --- .../Layers/ProviderCommandReactor.test.ts | 4 +- .../Layers/ProviderCommandReactor.ts | 3 - .../provider/Layers/CopilotAdapter.test.ts | 64 ++++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 32 +++++++++- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index af97c78e83d2..3bee21abb5bd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -782,8 +782,8 @@ describe("ProviderCommandReactor", () => { 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..."; + "Please investigate reconnect failures... after restarting the session and make the startup path reliable."; + const seededTitle = "Please investigate reconnect failures... after res..."; harness.generateThreadTitle.mockReturnValue( Effect.succeed({ title: "Reconnect startup reliability", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d66c352899a8..b623acf2355e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -143,10 +143,7 @@ function canReplaceThreadTitle( return false; } - const currentLooksUserEditedFromTruncatedSeed = - trimmedTitleSeed.endsWith("...") && trimmedCurrentTitle.includes("..."); return ( - !currentLooksUserEditedFromTruncatedSeed && truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed ); } diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index dbcef6874876..70b02958c85a 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -3073,7 +3073,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits command metadata separately from command output", () => + it.effect("emits command metadata and accumulates activity stream content", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-command-metadata"); @@ -3112,6 +3112,26 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-command", }, } as SessionEvent); + emit({ + id: "evt-copilot-command-reasoning-1", + timestamp, + parentId: null, + type: "assistant.reasoning_delta", + data: { + reasoningId: "reasoning-command", + deltaContent: "Thinking through ", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-reasoning-2", + timestamp, + parentId: null, + type: "assistant.reasoning_delta", + data: { + reasoningId: "reasoning-command", + deltaContent: "the command", + }, + } as SessionEvent); emit({ id: "evt-copilot-command-start", timestamp, @@ -3125,6 +3145,26 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, }, } as SessionEvent); + emit({ + id: "evt-copilot-command-output-1", + timestamp, + parentId: null, + type: "tool.execution_partial_result", + data: { + toolCallId: "tool-command", + partialOutput: "On branch ", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-command-output-2", + timestamp, + parentId: null, + type: "tool.execution_partial_result", + data: { + toolCallId: "tool-command", + partialOutput: "main", + }, + } as SessionEvent); for ( let attempt = 0; attempt < 20 && @@ -3190,6 +3230,28 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); } + const reasoningDeltas = runtimeEvents.filter( + (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text", + ); + NodeAssert.equal(reasoningDeltas.length, 2); + NodeAssert.equal(reasoningDeltas[0]?.eventId, reasoningDeltas[1]?.eventId); + NodeAssert.deepStrictEqual( + reasoningDeltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), + ["Thinking through ", "Thinking through the command"], + ); + + const commandOutputDeltas = runtimeEvents.filter( + (event) => event.type === "content.delta" && event.payload.streamKind === "command_output", + ); + NodeAssert.equal(commandOutputDeltas.length, 2); + NodeAssert.equal(commandOutputDeltas[0]?.eventId, commandOutputDeltas[1]?.eventId); + NodeAssert.deepStrictEqual( + commandOutputDeltas.map((event) => + event.type === "content.delta" ? event.payload.delta : "", + ), + ["On branch ", "On branch main"], + ); + yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 8fe8258bc320..2e3fcd3786cc 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -345,6 +345,14 @@ function createBaseEvent(input: { }; } +function contentActivityEventId(input: { + readonly threadId: ThreadId; + readonly itemId: string; + readonly streamKind: "reasoning_text" | "command_output" | "file_change_output" | "unknown"; +}): EventId { + return EventId.make(`copilot-content:${input.threadId}:${input.itemId}:${input.streamKind}`); +} + function ensureTurnSnapshot(context: CopilotSessionContext, turnId: TurnId): CopilotTurnSnapshot { const existing = context.turns.find((turn) => turn.id === turnId); if (existing) { @@ -1676,6 +1684,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( input.context.turnIdsWithAssistantText.add(input.turnId); input.context.assistantItemIdByTurnId.set(input.turnId, input.itemId); } + const isReasoning = input.streamKind === "reasoning_text"; await emitAsync({ ...createBaseEvent({ threadId: input.context.threadId, @@ -1683,10 +1692,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( itemId: input.itemId, raw: input.raw, }), + ...(isReasoning + ? { + eventId: contentActivityEventId({ + threadId: input.context.threadId, + itemId: input.itemId, + streamKind: input.streamKind, + }), + } + : {}), type: "content.delta", payload: { streamKind: input.streamKind, - delta, + delta: isReasoning ? input.nextText : delta, }, }); }; @@ -2696,6 +2714,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-tool-${event.data.toolCallId}`; const toolMeta = context.toolMetaById.get(event.data.toolCallId); + const streamKind = toolStreamKind(toolMeta?.itemType); + const output = `${context.emittedTextByItemId.get(itemId) ?? ""}${event.data.partialOutput}`; + context.emittedTextByItemId.set(itemId, output); await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -2703,10 +2724,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( itemId, raw: event, }), + eventId: contentActivityEventId({ + threadId: context.threadId, + itemId, + streamKind, + }), type: "content.delta", payload: { - streamKind: toolStreamKind(toolMeta?.itemType), - delta: event.data.partialOutput, + streamKind, + delta: output, }, }); return; From 87706776e6ef4005e521edbbd0e2324dbfa9dc1e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:10:55 +0200 Subject: [PATCH 114/155] fix: stabilize Copilot tool activity IDs --- .../provider/Layers/CopilotAdapter.test.ts | 29 ++++++++++++------- .../src/provider/Layers/CopilotAdapter.ts | 5 +--- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 70b02958c85a..a3d58da513f9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -3133,30 +3133,30 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } as SessionEvent); emit({ - id: "evt-copilot-command-start", + id: "evt-copilot-command-output-before-start", timestamp, parentId: null, - type: "tool.execution_start", + type: "tool.execution_partial_result", data: { toolCallId: "tool-command", - toolName: "bash", - arguments: { - command: "git status --short", - }, + partialOutput: "On branch ", }, } as SessionEvent); emit({ - id: "evt-copilot-command-output-1", + id: "evt-copilot-command-start", timestamp, parentId: null, - type: "tool.execution_partial_result", + type: "tool.execution_start", data: { toolCallId: "tool-command", - partialOutput: "On branch ", + toolName: "bash", + arguments: { + command: "git status --short", + }, }, } as SessionEvent); emit({ - id: "evt-copilot-command-output-2", + id: "evt-copilot-command-output-after-start", timestamp, parentId: null, type: "tool.execution_partial_result", @@ -3241,10 +3241,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); const commandOutputDeltas = runtimeEvents.filter( - (event) => event.type === "content.delta" && event.payload.streamKind === "command_output", + (event) => + event.type === "content.delta" && String(event.itemId) === "copilot-tool-tool-command", ); NodeAssert.equal(commandOutputDeltas.length, 2); NodeAssert.equal(commandOutputDeltas[0]?.eventId, commandOutputDeltas[1]?.eventId); + NodeAssert.deepStrictEqual( + commandOutputDeltas.map((event) => + event.type === "content.delta" ? event.payload.streamKind : undefined, + ), + ["unknown", "command_output"], + ); NodeAssert.deepStrictEqual( commandOutputDeltas.map((event) => event.type === "content.delta" ? event.payload.delta : "", diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 2e3fcd3786cc..e7a4c750d0b5 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -348,9 +348,8 @@ function createBaseEvent(input: { function contentActivityEventId(input: { readonly threadId: ThreadId; readonly itemId: string; - readonly streamKind: "reasoning_text" | "command_output" | "file_change_output" | "unknown"; }): EventId { - return EventId.make(`copilot-content:${input.threadId}:${input.itemId}:${input.streamKind}`); + return EventId.make(`copilot-content:${input.threadId}:${input.itemId}`); } function ensureTurnSnapshot(context: CopilotSessionContext, turnId: TurnId): CopilotTurnSnapshot { @@ -1697,7 +1696,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( eventId: contentActivityEventId({ threadId: input.context.threadId, itemId: input.itemId, - streamKind: input.streamKind, }), } : {}), @@ -2727,7 +2725,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( eventId: contentActivityEventId({ threadId: context.threadId, itemId, - streamKind, }), type: "content.delta", payload: { From 5f3f6b54640aa90de4041d3903a007c041d973fa Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:28:15 +0200 Subject: [PATCH 115/155] fix(server): preserve active Copilot turn on queued send failure --- .../provider/Layers/CopilotAdapter.test.ts | 37 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 12 ++++-- 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 a3d58da513f9..5db6eac700ca 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -4857,4 +4857,41 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("preserves the active turn when a queued Copilot send rejects", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-queued-send-failure-preserves-active-turn"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "keep this turn running", + attachments: [], + }); + + runtimeMock.state.lastSession.send.mockRejectedValueOnce(new Error("Queued send rejected")); + const queuedTurnResult = yield* adapter + .sendTurn({ + threadId, + input: "reject this queued turn", + attachments: [], + }) + .pipe(Effect.result); + + NodeAssert.equal(queuedTurnResult._tag, "Failure"); + const sessions = yield* adapter.listSessions(); + NodeAssert.equal(sessions.at(0)?.status, "running"); + NodeAssert.equal(String(sessions.at(0)?.activeTurnId), String(activeTurn.turnId)); + NodeAssert.equal(sessions.at(0)?.lastError, undefined); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index e7a4c750d0b5..9b4093e73b72 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1495,15 +1495,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (context.activeTurnId === turnId) { context.activeTurnId = undefined; } + const remainingActiveTurnId = context.activeTurnId; updateProviderSession(context, { - status: - status === "failed" + status: remainingActiveTurnId + ? "running" + : status === "failed" ? "error" : context.stopped ? "closed" : readyStatusAfterTurnCompletion(context), - ...(status === "failed" && input?.errorMessage ? { lastError: input.errorMessage } : {}), - activeTurnId: undefined, + ...(status === "failed" && !remainingActiveTurnId && input?.errorMessage + ? { lastError: input.errorMessage } + : {}), + activeTurnId: remainingActiveTurnId, }); await emitAsync({ ...createBaseEvent({ From ddb15d6c9dc37101e7eb058b1416287ee7d3337a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:48:18 +0200 Subject: [PATCH 116/155] fix(copilot): activate queued turns when SDK starts --- .../provider/Layers/CopilotAdapter.test.ts | 23 ++++++++ .../src/provider/Layers/CopilotAdapter.ts | 58 +++++++++++++++---- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5db6eac700ca..152961f7f6eb 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -4064,6 +4064,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { input: "second prompt", attachments: [], }); + NodeAssert.deepStrictEqual( + runtimeEvents + .filter((event) => event.type === "turn.started") + .map((event) => String(event.turnId)), + [String(firstTurn.turnId)], + ); emit({ id: "evt-copilot-queued-first-turn-end", timestamp, @@ -4124,6 +4130,23 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-queued-second", }, } as SessionEvent); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.started" && String(event.turnId) === String(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + NodeAssert.deepStrictEqual( + runtimeEvents + .filter((event) => event.type === "turn.started") + .map((event) => String(event.turnId)), + [String(firstTurn.turnId), String(secondTurn.turnId)], + ); emit({ id: "evt-copilot-queued-second-turn-end", timestamp, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 9b4093e73b72..9b7e2c105e09 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -142,6 +142,12 @@ interface CopilotTurnSnapshot { readonly items: Array; } +interface CopilotTurnStartPayload { + readonly model?: string | undefined; + readonly effort?: CopilotReasoningEffort | undefined; + readonly contextTier?: CopilotContextTier | undefined; +} + interface PendingPermissionHandler { readonly signature: string; readonly deferred: Deferred.Deferred; @@ -203,6 +209,8 @@ interface CopilotSessionContext { readonly turnQueuedAtMsByTurnId: Map; readonly sdkTurnIdsToTurnIds: Map; readonly completedTurnIds: Set; + readonly emittedTurnStartedIds: Set; + readonly turnStartPayloadByTurnId: Map; readonly turnUsageByTurnId: Map; readonly pendingPermissionHandlersBySignature: Map>; readonly pendingPermissionEventsBySignature: Map< @@ -1260,6 +1268,31 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const emit = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); + const emitTurnStarted = ( + context: CopilotSessionContext, + turnId: TurnId, + raw?: SessionEvent, + ): Effect.Effect => { + if (context.emittedTurnStartedIds.has(turnId)) { + return Effect.void; + } + + const payload = context.turnStartPayloadByTurnId.get(turnId); + context.emittedTurnStartedIds.add(turnId); + return emit({ + ...createBaseEvent({ + threadId: context.threadId, + turnId, + raw, + }), + type: "turn.started", + payload: { + ...(payload?.model ? { model: payload.model } : {}), + ...(payload?.effort ? { effort: payload.effort } : {}), + ...(payload?.contextTier ? { contextTier: payload.contextTier } : {}), + }, + }); + }; const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => nativeEventLogger ? runWithContext( @@ -1491,6 +1524,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.turnEndEventsByTurnId.delete(turnId); context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); + context.turnStartPayloadByTurnId.delete(turnId); clearSdkTurnMappingsForTurn(context, turnId); if (context.activeTurnId === turnId) { context.activeTurnId = undefined; @@ -2395,6 +2429,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( status: "running", activeTurnId: turnId, }); + await runWithContext(emitTurnStarted(context, turnId, event)); await emitAsync({ ...createBaseEvent({ threadId: context.threadId, @@ -3100,6 +3135,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnQueuedAtMsByTurnId: new Map(), sdkTurnIdsToTurnIds: new Map(), completedTurnIds: new Set(), + emittedTurnStartedIds: new Set(), + turnStartPayloadByTurnId: new Map(), turnUsageByTurnId: new Map(), pendingPermissionHandlersBySignature: new Map(), pendingPermissionEventsBySignature: new Map(), @@ -3213,6 +3250,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const queuedAt = yield* DateTime.now; ensureTurnSnapshot(context, turnId); + context.turnStartPayloadByTurnId.set(turnId, { + model: modelSelection?.model ?? context.session.model, + effort: reasoningEffort, + contextTier, + }); context.turnQueuedAtMsByTurnId.set(turnId, epochMsFromIso(DateTime.formatIso(queuedAt)) ?? 0); context.queuedTurnIds.push(turnId); yield* Effect.promise(async () => { @@ -3232,18 +3274,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...(modelSelection?.model ? { model: modelSelection.model } : {}), }); - yield* emit({ - ...createBaseEvent({ - threadId: input.threadId, - turnId, - }), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(reasoningEffort ? { effort: reasoningEffort } : {}), - ...(contextTier ? { contextTier } : {}), - }, - }); + if (shouldPromoteQueuedTurn) { + yield* emitTurnStarted(context, turnId); + } const messageOptions: MessageOptions = { prompt: text ?? "", @@ -3259,6 +3292,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.queuedTurnIds.splice(queueIndex, 1); } context.turnQueuedAtMsByTurnId.delete(turnId); + context.turnStartPayloadByTurnId.delete(turnId); if (context.activeTurnId === turnId) { context.activeTurnId = undefined; } From 02a7988422e51129cabc3af121fd575465dd619f Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:51:43 +0200 Subject: [PATCH 117/155] fix(copilot): support conversation rollback --- .../provider/Layers/CopilotAdapter.test.ts | 105 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 77 +++++++++++-- 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 152961f7f6eb..df56bca4088f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -61,6 +61,9 @@ const runtimeMock = vi.hoisted(() => { mode: { set: vi.fn(async () => undefined), }, + history: { + truncate: vi.fn(async () => ({ removedEventCount: 0 })), + }, plan: { read: vi.fn(async () => ({ content: "" })), }, @@ -513,6 +516,108 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("rolls back persisted Copilot history from the first removed user message", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-rollback-history"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + const completeTurn = (sdkTurnId: string, eventPrefix: string) => { + config.onEvent?.({ + id: `${eventPrefix}-start`, + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: sdkTurnId }, + } as SessionEvent); + config.onEvent?.({ + id: `${eventPrefix}-end`, + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { turnId: sdkTurnId }, + } as SessionEvent); + config.onEvent?.({ + id: `${eventPrefix}-idle`, + timestamp, + parentId: null, + type: "session.idle", + data: { aborted: false }, + } as SessionEvent); + }; + + runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-first"); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + completeTurn("sdk-turn-first", "evt-first"); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-second"); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + completeTurn("sdk-turn-second", "evt-second"); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + const snapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ + [{ eventId: "user-message-second" }], + ]); + NodeAssert.deepStrictEqual( + snapshot.turns.map((turn) => String(turn.id)), + [String(firstTurn.turnId)], + ); + NodeAssert.deepStrictEqual( + (yield* adapter.readThread(threadId)).turns.map((turn) => String(turn.id)), + [String(firstTurn.turnId)], + ); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + 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 9b7e2c105e09..a4a95fcc1a85 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -140,6 +140,7 @@ export interface CopilotAdapterLiveOptions { interface CopilotTurnSnapshot { readonly id: TurnId; readonly items: Array; + sdkHistoryEventId?: string | undefined; } interface CopilotTurnStartPayload { @@ -1368,6 +1369,20 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( cause, }), }), + truncateHistory: ( + context: CopilotSessionContext, + eventId: string, + ): Effect.Effect => + Effect.tryPromise({ + try: () => context.sdkSession.rpc.history.truncate({ eventId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.history.truncate", + detail: detailFromCause(cause, "Failed to truncate Copilot history."), + cause, + }), + }).pipe(Effect.asVoid), readPlan: ( context: CopilotSessionContext, ): Effect.Effect => @@ -3331,6 +3346,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); if (trimOrUndefined(providerMessageId)) { context.turnIdByProviderItemId.set(providerMessageId, turnId); + ensureTurnSnapshot(context, turnId).sdkHistoryEventId = providerMessageId; } return { @@ -3532,18 +3548,61 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, _numTurns) { - if (!sessions.has(threadId)) { - return yield* new ProviderAdapterSessionNotFoundError({ + function* (threadId, numTurns) { + const context = yield* requireSessionContext(sessions, threadId); + if ( + context.activeTurnId !== undefined || + context.activeSdkTurnKey !== undefined || + context.queuedTurnIds.length > 0 + ) { + return yield* new ProviderAdapterRequestError({ provider: PROVIDER, - threadId, + method: "thread.rollback", + detail: "Cannot roll back Copilot history while a turn is active or queued.", }); } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread.rollback", - detail: "Copilot SDK does not expose thread rollback.", - }); + + const nextLength = Math.max(0, context.turns.length - numTurns); + const removedTurns = context.turns.slice(nextLength); + const sdkHistoryEventId = removedTurns.find( + (turn) => turn.sdkHistoryEventId, + )?.sdkHistoryEventId; + if (sdkHistoryEventId) { + yield* copilotSdk.truncateHistory(context, sdkHistoryEventId); + } + + context.turns.splice(nextLength); + const removedTurnIds = new Set(removedTurns.map((turn) => turn.id)); + for (const turnId of removedTurnIds) { + context.turnQueuedAtMsByTurnId.delete(turnId); + context.completedTurnIds.delete(turnId); + context.emittedTurnStartedIds.delete(turnId); + context.turnStartPayloadByTurnId.delete(turnId); + context.turnUsageByTurnId.delete(turnId); + context.assistantItemIdByTurnId.delete(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.emittedTurnDiffByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + context.turnEndEventsByTurnId.delete(turnId); + clearSdkTurnMappingsForTurn(context, turnId); + } + for (const [providerItemId, turnId] of context.turnIdByProviderItemId) { + if (!removedTurnIds.has(turnId)) { + continue; + } + context.turnIdByProviderItemId.delete(providerItemId); + context.emittedTextByItemId.delete(providerItemId); + context.toolMetaById.delete(providerItemId); + context.startedItemIds.delete(providerItemId); + } + + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; }, ); From 30fa713288f20d05b4c8b7dcc8a16231b51af003 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:52:46 +0200 Subject: [PATCH 118/155] fix(copilot): require mode sync before sending --- .../provider/Layers/CopilotAdapter.test.ts | 32 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 11 +++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index df56bca4088f..7f7620be074c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -651,6 +651,38 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("does not send a turn when Copilot mode synchronization fails", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-mode-sync-failure"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.rpc.mode.set.mockRejectedValueOnce( + new Error("Copilot mode update rejected"), + ); + runtimeMock.state.lastSession.send.mockClear(); + const result = yield* adapter + .sendTurn({ + threadId, + input: "do not send this prompt", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.lastSession.send.mock.calls.length, 0); + + 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 a4a95fcc1a85..ae8728f9aa6c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2119,7 +2119,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({ @@ -2134,6 +2134,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, }), ), + ); + + const syncSessionModeBestEffort = ( + context: CopilotSessionContext, + mode: CopilotMode, + ): Effect.Effect => + syncSessionMode(context, mode).pipe( Effect.catch((cause) => emit({ ...createBaseEvent({ @@ -3178,7 +3185,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }; sessions.set(input.threadId, context); - yield* syncSessionMode( + yield* syncSessionModeBestEffort( context, requestedCopilotMode({ runtimeMode: input.runtimeMode, From b3a00d5e390a09ce56749ed3a17938bd4affe95d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:54:36 +0200 Subject: [PATCH 119/155] fix(server): run first-turn metadata jobs concurrently --- .../Layers/ProviderCommandReactor.test.ts | 25 ++++++------ .../Layers/ProviderCommandReactor.ts | 18 ++++----- packages/shared/src/DrainableWorker.test.ts | 38 +++++++++++++++++++ packages/shared/src/DrainableWorker.ts | 27 ++++++++++--- 4 files changed, 81 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3bee21abb5bd..0f9afd66eb24 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -591,20 +591,18 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); - it("retries transient first-turn thread title generation failures", async () => { + it("does not retry 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" })); + harness.generateThreadTitle.mockReturnValueOnce( + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "transient provider error", + }), + ), + ); await runtime!.runPromise( harness.engine.dispatch({ @@ -633,12 +631,13 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 2); + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); 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"); + expect(harness.generateThreadTitle).toHaveBeenCalledOnce(); + expect(thread?.title).toBe(seededTitle); }); it("generates a Copilot thread title while starting the first Copilot turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b623acf2355e..3fdda63a88ff 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -743,14 +743,12 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - const generated = yield* Effect.suspend(() => - textGeneration.generateThreadTitle({ - cwd: input.cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }), - ).pipe(Effect.retry({ times: 2 })); + const generated = yield* textGeneration.generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }); if (!generated) return; const thread = yield* resolveThread(input.threadId); @@ -1095,7 +1093,9 @@ const make = Effect.gen(function* () { }), ); - const firstTurnAuxiliaryWorker = yield* makeDrainableWorker((job: Effect.Effect) => job); + const firstTurnAuxiliaryWorker = yield* makeDrainableWorker((job: Effect.Effect) => job, { + concurrency: "unbounded", + }); const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 8e4c654e2e4c..14ab32467c29 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -54,4 +54,42 @@ describe("makeDrainableWorker", () => { }), ), ); + + it.live("processes work concurrently while still waiting for every item when draining", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const release = yield* Deferred.make(); + + const worker = yield* makeDrainableWorker( + (item: string) => + Effect.gen(function* () { + yield* Deferred.succeed( + item === "first" ? firstStarted : secondStarted, + undefined, + ).pipe(Effect.orDie); + yield* Deferred.await(release); + }), + { concurrency: "unbounded" }, + ); + + yield* worker.enqueue("first"); + yield* worker.enqueue("second"); + yield* Deferred.await(firstStarted); + yield* Deferred.await(secondStarted); + + const drained = yield* Deferred.make(); + yield* Effect.forkChild( + worker.drain.pipe( + Effect.tap(() => Deferred.succeed(drained, undefined).pipe(Effect.orDie)), + ), + ); + expect(yield* Deferred.isDone(drained)).toBe(false); + + yield* Deferred.succeed(release, undefined); + yield* Deferred.await(drained); + }), + ), + ); }); diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index de40ec5e36b8..4e88ff042807 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -28,6 +28,11 @@ export interface DrainableWorker { readonly drain: Effect.Effect; } +export interface DrainableWorkerOptions { + /** Process queued items concurrently. Sequential processing remains the default. */ + readonly concurrency?: "unbounded" | undefined; +} + /** * Create a drainable worker that processes items from an unbounded queue. * @@ -39,22 +44,34 @@ export interface DrainableWorker { */ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, + options?: DrainableWorkerOptions, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); const outstanding = yield* TxRef.make(0); - yield* TxQueue.take(queue).pipe( - Effect.tap((a) => + const processNext = TxQueue.take(queue).pipe( + Effect.flatMap((item) => Effect.ensuring( - process(a), + process(item), TxRef.update(outstanding, (n) => n - 1), ), ), - Effect.forever, - Effect.forkScoped, ); + yield* ( + options?.concurrency === "unbounded" + ? TxQueue.take(queue).pipe( + Effect.flatMap((item) => + Effect.ensuring( + process(item), + TxRef.update(outstanding, (n) => n - 1), + ).pipe(Effect.forkScoped), + ), + ) + : processNext + ).pipe(Effect.forever, Effect.forkScoped); + const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( Effect.tap((n) => (n > 0 ? Effect.txRetry : Effect.void)), Effect.tx, From b4977c469d1809a14521a6345d08e219e2fccc8b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:55:47 +0200 Subject: [PATCH 120/155] perf(server): stop persisting streamed tool deltas --- .../Layers/ProviderRuntimeIngestion.test.ts | 103 ++---------------- .../Layers/ProviderRuntimeIngestion.ts | 67 ------------ 2 files changed, 9 insertions(+), 161 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 951b31cc93d7..88b5902501d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2862,7 +2862,7 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); - it("projects reasoning text deltas into normalized thread activities", async () => { + it("does not persist streamed deltas as thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2880,57 +2880,6 @@ describe("ProviderRuntimeIngestion", () => { contentIndex: 0, }, }); - harness.emit({ - type: "content.delta", - eventId: asEventId("evt-reasoning-summary-delta"), - provider: ProviderDriverKind.make("codex"), - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-reasoning"), - itemId: asItemId("item-reasoning-summary"), - payload: { - streamKind: "reasoning_summary_text", - delta: "Implementation summary", - summaryIndex: 1, - }, - }); - - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "reasoning.update", - ) && - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "reasoning.summary", - ), - ); - - const reasoning = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-reasoning-delta", - ); - expect(reasoning?.kind).toBe("reasoning.update"); - expect(reasoning?.payload).toMatchObject({ - detail: "Thinking through the implementation", - streamKind: "reasoning_text", - contentIndex: 0, - }); - - const summary = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-reasoning-summary-delta", - ); - expect(summary?.kind).toBe("reasoning.summary"); - expect(summary?.payload).toMatchObject({ - detail: "Implementation summary", - streamKind: "reasoning_summary_text", - summaryIndex: 1, - }); - }); - - it("projects provider tool output deltas into normalized thread activities", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - harness.emit({ type: "content.delta", eventId: asEventId("evt-command-output-delta"), @@ -2944,49 +2893,15 @@ describe("ProviderRuntimeIngestion", () => { 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", + await harness.drain(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), ); - 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", - }); + expect( + thread?.activities.some((activity) => + ["evt-reasoning-delta", "evt-command-output-delta"].includes(activity.id), + ), + ).toBe(false); }); it("projects context window updates into normalized thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index ca0a8f8d4373..663ef197d925 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -554,73 +554,6 @@ function runtimeEventToActivities( ]; } - case "content.delta": { - if (event.payload.delta.length === 0) { - return []; - } - if ( - event.payload.streamKind === "command_output" || - event.payload.streamKind === "file_change_output" || - event.payload.streamKind === "unknown" - ) { - const summary = - event.payload.streamKind === "command_output" - ? "Command output" - : event.payload.streamKind === "file_change_output" - ? "File change output" - : "Tool output"; - return [ - { - id: event.eventId, - createdAt: event.createdAt, - tone: "tool", - kind: "tool.output", - summary, - payload: { - detail: truncateDetail(event.payload.delta), - streamKind: event.payload.streamKind, - ...(event.itemId ? { itemId: event.itemId } : {}), - }, - turnId: toTurnId(event.turnId) ?? null, - ...maybeSequence, - }, - ]; - } - if ( - event.payload.streamKind !== "reasoning_text" && - event.payload.streamKind !== "reasoning_summary_text" - ) { - return []; - } - return [ - { - id: event.eventId, - createdAt: event.createdAt, - tone: "info", - kind: - event.payload.streamKind === "reasoning_summary_text" - ? "reasoning.summary" - : "reasoning.update", - summary: - event.payload.streamKind === "reasoning_summary_text" - ? "Reasoning summary" - : "Reasoning update", - payload: { - detail: truncateDetail(event.payload.delta), - streamKind: event.payload.streamKind, - ...(event.payload.contentIndex !== undefined - ? { contentIndex: event.payload.contentIndex } - : {}), - ...(event.payload.summaryIndex !== undefined - ? { summaryIndex: event.payload.summaryIndex } - : {}), - }, - turnId: toTurnId(event.turnId) ?? null, - ...maybeSequence, - }, - ]; - } - case "item.updated": { if (!isToolLifecycleItemType(event.payload.itemType)) { return []; From c70d274faecb2ce13c542f5901ba168fd93656bc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:57:48 +0200 Subject: [PATCH 121/155] fix(copilot): separate call usage from context usage --- .../provider/Layers/CopilotAdapter.test.ts | 76 ++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 11 --- apps/web/src/lib/contextWindow.test.ts | 56 ------------ apps/web/src/lib/contextWindow.ts | 90 +++++++------------ 4 files changed, 109 insertions(+), 124 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 7f7620be074c..f24707bb96a9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -618,6 +618,82 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("keeps assistant call usage on the turn without publishing context usage", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-assistant-call-usage"); + + 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 turn = yield* adapter.sendTurn({ + threadId, + input: "measure this turn", + attachments: [], + }); + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-call-usage-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-call-usage" }, + } as SessionEvent); + config.onEvent({ + id: "evt-call-usage", + timestamp, + parentId: null, + type: "assistant.usage", + ephemeral: true, + data: { + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 5, + duration: 250, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* adapter.stopSession(threadId); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "thread.token-usage.updated"), + false, + ); + const completed = runtimeEvents.find( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.payload.usage, { + usedTokens: 125, + lastUsedTokens: 125, + inputTokens: 100, + lastInputTokens: 100, + cachedInputTokens: 20, + lastCachedInputTokens: 20, + outputTokens: 5, + lastOutputTokens: 5, + durationMs: 250, + }); + } + }), + ); + 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 ae8728f9aa6c..627bb57d727e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2652,17 +2652,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } 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": { diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index 3be3a3255a6c..c3226884a31d 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -61,62 +61,6 @@ 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 88d30ce65a01..8a8200953282 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -49,76 +49,52 @@ 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) { + if (!activity || activity.kind !== "context-window.updated") { continue; } - if (activity.kind === "context-compaction") { - break; - } - - if (activity.kind !== "context-window.updated") { - continue; - } - - const snapshot = snapshotFromActivity(activity); - if (!snapshot) { + const payload = asRecord(activity.payload); + const usedTokens = asFiniteNumber(payload?.usedTokens); + if (usedTokens === null || usedTokens < 0) { continue; } - const maxTokens = snapshot.maxTokens ?? null; - if (maxTokens !== null && maxTokens > 0) { - return snapshot; - } - - latestTokenOnlySnapshot ??= snapshot; + 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, + }; } - return latestTokenOnlySnapshot; + return null; } export function formatContextWindowTokens(value: number | null): string { From 72aacff270e230964593b14717dc0fc20c6d4ccb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 11:59:49 +0200 Subject: [PATCH 122/155] fix(server): align bundled Copilot CLI version --- apps/server/package.json | 2 +- pnpm-lock.yaml | 97 +------------------------- scripts/build-desktop-artifact.test.ts | 4 +- 3 files changed, 5 insertions(+), 98 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 6a39418dda6b..2ead40a36739 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,7 +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": "1.0.68", "@github/copilot-sdk": "1.0.5", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cfc7b6820f0..4469fc7399ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,8 +457,8 @@ importers: specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@github/copilot': - specifier: 1.0.60 - version: 1.0.60 + specifier: 1.0.68 + version: 1.0.68 '@github/copilot-sdk': specifier: 1.0.5 version: 1.0.5 @@ -2702,37 +2702,18 @@ packages: '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} - '@github/copilot-darwin-arm64@1.0.60': - resolution: {integrity: sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==} - cpu: [arm64] - os: [darwin] - hasBin: true - '@github/copilot-darwin-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] @@ -2740,13 +2721,6 @@ packages: 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] @@ -2754,13 +2728,6 @@ packages: 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] @@ -2768,13 +2735,6 @@ packages: 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-linuxmusl-x64@1.0.68': resolution: {integrity: sha512-oOMXZ9HPJAJaKSrXZIYuond4uOiUkK1uRhVPI3Cs74n7uEVKPWpNlTN+j/O754dnBa1+HJcuZSDMulTbnOgirg==} cpu: [x64] @@ -2786,34 +2746,18 @@ packages: 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==} - cpu: [arm64] - 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 @@ -12824,39 +12768,21 @@ snapshots: '@formkit/auto-animate@0.9.0': {} - '@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-linuxmusl-x64@1.0.68': optional: true @@ -12866,31 +12792,12 @@ snapshots: 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 - optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.60 - '@github/copilot-darwin-x64': 1.0.60 - '@github/copilot-linux-arm64': 1.0.60 - '@github/copilot-linux-x64': 1.0.60 - '@github/copilot-linuxmusl-arm64': 1.0.60 - '@github/copilot-linuxmusl-x64': 1.0.60 - '@github/copilot-win32-arm64': 1.0.60 - '@github/copilot-win32-x64': 1.0.60 - '@github/copilot@1.0.68': dependencies: detect-libc: 2.1.2 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 030c56a6af84..3185a04e575f 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -177,14 +177,14 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( { - "@github/copilot": "1.0.60", + "@github/copilot": "1.0.68", "@github/copilot-sdk": "1.0.0", "@t3tools/contracts": "workspace:*", }, {}, ), { - "@github/copilot": "1.0.60", + "@github/copilot": "1.0.68", "@github/copilot-sdk": "1.0.0", }, ); From 65164248002218bff34c1c519eac9d71a6331783 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 12:00:46 +0200 Subject: [PATCH 123/155] test(copilot): type mocked send responses --- apps/server/src/provider/Layers/CopilotAdapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index f24707bb96a9..650ccaec8a44 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -77,7 +77,7 @@ const runtimeMock = vi.hoisted(() => { }, disconnect: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), - send: vi.fn(async () => undefined), + send: vi.fn(async (): Promise => undefined), abort: vi.fn(async () => undefined), }); From ad70a3eb2812e2b2e5537a7a005ee8127ff3cd65 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 12:08:45 +0200 Subject: [PATCH 124/155] fix(copilot): coalesce text client startup --- .../CopilotTextGeneration.test.ts | 44 +++++++++++++- .../textGeneration/CopilotTextGeneration.ts | 57 +++++++++++++------ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index ed48ab100d3a..9751e7221396 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, it } from "@effect/vitest"; import { CopilotSettings, ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; @@ -24,6 +25,7 @@ const runtimeMock = vi.hoisted(() => { readonly disconnect: ReturnType; readonly sendAndWait: ReturnType; }>, + clientStartGate: null as Promise | null, }; return { @@ -32,6 +34,7 @@ const runtimeMock = vi.hoisted(() => { state.createdClients = []; state.sessionConfigs = []; state.sessions = []; + state.clientStartGate = null; }, }; }); @@ -45,7 +48,9 @@ vi.mock("../provider/copilotRuntime.ts", async () => { ...actual, createCopilotClient: vi.fn( (input: { readonly cwd?: string; readonly baseDirectory?: string }) => { - const start = vi.fn(async () => undefined); + const start = vi.fn(async () => { + await runtimeMock.state.clientStartGate; + }); const stop = vi.fn(async () => undefined); const createSession = vi.fn(async (config: unknown) => { runtimeMock.state.sessionConfigs.push(config); @@ -133,6 +138,43 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { }), ); + it.effect("coalesces concurrent Copilot client startup", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + let releaseClientStart: (() => void) | undefined; + runtimeMock.state.clientStartGate = new Promise((resolve) => { + releaseClientStart = resolve; + }); + + const request = 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 requestsFiber = yield* Effect.all([request, request], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + + for ( + let attempt = 0; + attempt < 20 && runtimeMock.state.createdClients.length === 0; + attempt += 1 + ) { + yield* Effect.yieldNow; + } + expect(runtimeMock.state.createdClients).toHaveLength(1); + + releaseClientStart?.(); + const results = yield* Fiber.join(requestsFiber); + expect(results.map((result) => result.subject)).toEqual(["Add change", "Add change"]); + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.createdClients[0]?.client.start).toHaveBeenCalledOnce(); + }), + ); + it.effect("passes the configured Copilot base directory to shared clients", () => Effect.gen(function* () { const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings, process.env, { diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index b1b4f91cf59b..93ea2cf2ae86 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -57,6 +57,17 @@ interface SharedCopilotTextClientLease { readonly client: CopilotClient; } +type SharedCopilotTextClientAcquisition = + | { readonly _tag: "Existing"; readonly client: CopilotClient } + | { + readonly _tag: "Pending"; + readonly deferred: Deferred.Deferred; + } + | { + readonly _tag: "Start"; + readonly deferred: Deferred.Deferred; + }; + function isTextGenerationError(error: unknown): error is TextGenerationError { return ( typeof error === "object" && @@ -178,26 +189,40 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( cwd: input.cwd, }); - // First check if a client is already started — done inside the mutex so the - // lookup and activeRequests increment are atomic and cannot race with the - // idle-close fiber removing the entry. - const existingClient = yield* sharedClientMutex.withPermit( + const acquisition = yield* sharedClientMutex.withPermit( Effect.gen(function* () { const existing = sharedClients.get(clientKey); - if (!existing) return null; - yield* cancelIdleCloseFiber(existing); - existing.activeRequests += 1; - return existing.client; + if (existing) { + yield* cancelIdleCloseFiber(existing); + existing.activeRequests += 1; + return { + _tag: "Existing", + client: existing.client, + } satisfies SharedCopilotTextClientAcquisition; + } + + const pending = pendingClients.get(clientKey); + if (pending) { + return { + _tag: "Pending", + deferred: pending, + } satisfies SharedCopilotTextClientAcquisition; + } + + const deferred = yield* Deferred.make(); + pendingClients.set(clientKey, deferred); + return { + _tag: "Start", + deferred, + } satisfies SharedCopilotTextClientAcquisition; }), ); - if (existingClient !== null) { - return { clientKey, client: existingClient }; + if (acquisition._tag === "Existing") { + return { clientKey, client: acquisition.client }; } - // Check if a client is currently being started (pending) - const pendingDeferred = pendingClients.get(clientKey); - if (pendingDeferred) { - const client = yield* Deferred.await(pendingDeferred); + if (acquisition._tag === "Pending") { + const client = yield* Deferred.await(acquisition.deferred); yield* sharedClientMutex.withPermit( Effect.gen(function* () { const state = sharedClients.get(clientKey); @@ -210,9 +235,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( return { clientKey, client }; } - // Create a deferred for this client startup - const startupDeferred = yield* Deferred.make(); - pendingClients.set(clientKey, startupDeferred); + const startupDeferred = acquisition.deferred; // Start the client. If any step fails we must fail and remove // startupDeferred so pending waiters are unblocked rather than From 5f068203b96d8f8dc2a9a8890dc0549be76c9854 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 12:10:43 +0200 Subject: [PATCH 125/155] refactor(copilot): remove unused delta emissions --- .../provider/Layers/CopilotAdapter.test.ts | 32 +--- .../src/provider/Layers/CopilotAdapter.ts | 162 +----------------- 2 files changed, 13 insertions(+), 181 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 650ccaec8a44..fe9139308f61 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -3286,7 +3286,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits command metadata and accumulates activity stream content", () => + it.effect("emits command metadata without unused reasoning or tool output deltas", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-command-metadata"); @@ -3443,33 +3443,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }); } - const reasoningDeltas = runtimeEvents.filter( - (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text", - ); - NodeAssert.equal(reasoningDeltas.length, 2); - NodeAssert.equal(reasoningDeltas[0]?.eventId, reasoningDeltas[1]?.eventId); - NodeAssert.deepStrictEqual( - reasoningDeltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), - ["Thinking through ", "Thinking through the command"], - ); - - const commandOutputDeltas = runtimeEvents.filter( - (event) => - event.type === "content.delta" && String(event.itemId) === "copilot-tool-tool-command", - ); - NodeAssert.equal(commandOutputDeltas.length, 2); - NodeAssert.equal(commandOutputDeltas[0]?.eventId, commandOutputDeltas[1]?.eventId); - NodeAssert.deepStrictEqual( - commandOutputDeltas.map((event) => - event.type === "content.delta" ? event.payload.streamKind : undefined, - ), - ["unknown", "command_output"], - ); - NodeAssert.deepStrictEqual( - commandOutputDeltas.map((event) => - event.type === "content.delta" ? event.payload.delta : "", - ), - ["On branch ", "On branch main"], + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "content.delta"), + false, ); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 627bb57d727e..7be901fb2324 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -354,13 +354,6 @@ function createBaseEvent(input: { }; } -function contentActivityEventId(input: { - readonly threadId: ThreadId; - readonly itemId: string; -}): EventId { - return EventId.make(`copilot-content:${input.threadId}:${input.itemId}`); -} - function ensureTurnSnapshot(context: CopilotSessionContext, turnId: TurnId): CopilotTurnSnapshot { const existing = context.turns.find((turn) => turn.id === turnId); if (existing) { @@ -854,18 +847,6 @@ function extractPlanStepsFromTodoInput(input: Record): PlanStep return steps.length > 0 ? steps : undefined; } -function toolStreamKind( - itemType: ToolMeta["itemType"] | undefined, -): "command_output" | "file_change_output" | "unknown" { - if (itemType === "command_execution") { - return "command_output"; - } - if (itemType === "file_change") { - return "file_change_output"; - } - return "unknown"; -} - function isStringRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -1700,12 +1681,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; - const emitTextDelta = async (input: { + const emitAssistantTextDelta = 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; }) => { @@ -1720,7 +1699,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), type: "item.started", payload: { - itemType: input.itemType, + itemType: "assistant_message", status: "inProgress", }, }); @@ -1732,11 +1711,8 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (delta.length === 0) { return; } - if (input.itemType === "assistant_message" && input.streamKind === "assistant_text") { - input.context.turnIdsWithAssistantText.add(input.turnId); - input.context.assistantItemIdByTurnId.set(input.turnId, input.itemId); - } - const isReasoning = input.streamKind === "reasoning_text"; + input.context.turnIdsWithAssistantText.add(input.turnId); + input.context.assistantItemIdByTurnId.set(input.turnId, input.itemId); await emitAsync({ ...createBaseEvent({ threadId: input.context.threadId, @@ -1744,18 +1720,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( itemId: input.itemId, raw: input.raw, }), - ...(isReasoning - ? { - eventId: contentActivityEventId({ - threadId: input.context.threadId, - itemId: input.itemId, - }), - } - : {}), type: "content.delta", payload: { - streamKind: input.streamKind, - delta: isReasoning ? input.nextText : delta, + streamKind: "assistant_text", + delta, }, }); }; @@ -1772,12 +1740,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.pendingTaskCompletionTextByTurnId.delete(turnId); const itemId = `copilot-task-completion-${String(turnId)}`; - await emitTextDelta({ + await emitAssistantTextDelta({ context, turnId, itemId, - itemType: "assistant_message", - streamKind: "assistant_text", nextText: content, raw, }); @@ -2466,29 +2432,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); return; } - case "assistant.reasoning_delta": { - const turnId = resolveTurnIdForEvent(context, { - sdkTurnId: context.activeSdkTurnId, - sdkEventTimestamp: event.timestamp, - agentId: event.agentId, - providerItemId: event.data.reasoningId, - }); - if (!turnId) { - return; - } - const itemId = `copilot-reasoning-${event.data.reasoningId}`; - context.turnIdByProviderItemId.set(event.data.reasoningId, turnId); - await emitTextDelta({ - context, - turnId, - itemId, - itemType: "reasoning", - streamKind: "reasoning_text", - nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, - raw: event, - }); - return; - } case "assistant.reasoning": { const turnId = resolveTurnIdForEvent(context, { sdkTurnId: context.activeSdkTurnId, @@ -2499,30 +2442,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, @@ -2544,12 +2464,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); context.assistantItemIdByTurnId.set(turnId, itemId); - await emitTextDelta({ + await emitAssistantTextDelta({ context, turnId, itemId, - itemType: "assistant_message", - streamKind: "assistant_text", nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, raw: event, }); @@ -2569,12 +2487,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); context.assistantItemIdByTurnId.set(turnId, itemId); - await emitTextDelta({ + await emitAssistantTextDelta({ context, turnId, itemId, - itemType: "assistant_message", - streamKind: "assistant_text", nextText: event.data.content, raw: event, }); @@ -2591,31 +2507,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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, @@ -2749,41 +2640,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); return; } - case "tool.execution_partial_result": { - const turnId = resolveTurnIdForEvent(context, { - providerItemId: event.data.toolCallId, - sdkTurnId: context.activeSdkTurnId, - sdkEventTimestamp: event.timestamp, - agentId: event.agentId, - allowActiveFallback: false, - }); - if (!turnId) { - return; - } - const itemId = `copilot-tool-${event.data.toolCallId}`; - const toolMeta = context.toolMetaById.get(event.data.toolCallId); - const streamKind = toolStreamKind(toolMeta?.itemType); - const output = `${context.emittedTextByItemId.get(itemId) ?? ""}${event.data.partialOutput}`; - context.emittedTextByItemId.set(itemId, output); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - itemId, - raw: event, - }), - eventId: contentActivityEventId({ - threadId: context.threadId, - itemId, - }), - type: "content.delta", - payload: { - streamKind, - delta: output, - }, - }); - return; - } case "tool.execution_progress": { const turnId = resolveTurnIdForEvent(context, { providerItemId: event.data.toolCallId, From 436312fc1e32cde0528986999132f75396a63528 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 12:12:31 +0200 Subject: [PATCH 126/155] refactor(copilot): keep client-seeded thread titles --- .../Layers/ProviderCommandReactor.test.ts | 7 +++--- .../Layers/ProviderCommandReactor.ts | 7 ++++++ .../CopilotTextGeneration.test.ts | 16 +++++++------ .../textGeneration/CopilotTextGeneration.ts | 24 +++++++------------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0f9afd66eb24..5409409ac075 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -640,7 +640,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe(seededTitle); }); - it("generates a Copilot thread title while starting the first Copilot turn", async () => { + it("keeps the client-seeded title while starting the first Copilot turn", async () => { const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); const harness = await createHarness({ threadModelSelection: copilotSelection, @@ -648,7 +648,6 @@ describe("ProviderCommandReactor", () => { }); 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({ @@ -680,10 +679,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 3fdda63a88ff..e514131b0609 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_DRIVER_KIND = ProviderDriverKind.make("copilot"); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -743,6 +744,12 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + const textGenerationProvider = yield* providerService.getInstanceInfo( + modelSelection.instanceId, + ); + if (textGenerationProvider.driverKind === COPILOT_DRIVER_KIND) { + return; + } const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 9751e7221396..424a798d3ac2 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -221,18 +221,20 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { }), ); - it.effect("generates thread titles without starting a Copilot SDK session", () => + it.effect("keeps client-seeded 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, - }); + const result = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Investigate Copilot thread startup errors after reconnecting.", + modelSelection, + }) + .pipe(Effect.result); - expect(title.title).toBe("Investigate Copilot thread startup errors after..."); + expect(result._tag).toBe("Failure"); 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 93ea2cf2ae86..a5661ac90168 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -31,7 +31,6 @@ import { makeBranchNameGenerationResult, makeCommitMessageGenerationResult, makePrContentGenerationResult, - makeThreadTitleGenerationResult, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; @@ -41,8 +40,7 @@ const COPILOT_TEXT_GENERATION_IDLE_TTL = "30 seconds"; type CopilotTextGenerationOperation = | "generateCommitMessage" | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + | "generateBranchName"; type CopilotReasoningEffort = NonNullable; type CopilotContextTier = NonNullable; @@ -102,16 +100,6 @@ 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, @@ -533,9 +521,13 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ); }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( - input, - ) => Effect.succeed(copilotThreadTitleFallback(input)); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Copilot uses the client-seeded thread title.", + }), + ); return { generateCommitMessage, From 8d4ebc01240763d154f73204f4f28c96596840dd Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 12:36:36 +0200 Subject: [PATCH 127/155] fix(server): address Copilot review feedback --- .../Layers/ProviderCommandReactor.test.ts | 46 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 14 +++++- .../provider/Layers/CopilotAdapter.test.ts | 9 +++- .../src/provider/Layers/CopilotAdapter.ts | 38 +++++++++++++-- .../CopilotTextGeneration.test.ts | 16 +++---- .../textGeneration/CopilotTextGeneration.ts | 21 ++++++--- 6 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5409409ac075..b0f040c7e0db 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -685,6 +685,52 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe(seededTitle); }); + it("replaces the default Copilot title with the local fallback", 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"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Investigate Copilot reconnect failures" }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-copilot-default"), + threadId: ThreadId.make("thread-1"), + title: "New thread", + }), + ); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-copilot-title-fallback"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-copilot-title-fallback"), + role: "user", + text: "Investigate Copilot reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: "Investigate Copilot reconnect failures after...", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Investigate Copilot reconnect failures"); + }); + 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"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e514131b0609..9f3c41c6fd66 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -748,7 +748,19 @@ const make = Effect.gen(function* () { modelSelection.instanceId, ); if (textGenerationProvider.driverKind === COPILOT_DRIVER_KIND) { - return; + const thread = yield* resolveThread(input.threadId); + if (!thread) return; + + const currentTitle = thread.title.trim(); + const titleSeed = input.titleSeed?.trim(); + const hasClientSeededTitle = + currentTitle !== DEFAULT_THREAD_TITLE && + titleSeed !== undefined && + titleSeed.length > 0 && + currentTitle === titleSeed; + if (hasClientSeededTitle) { + return; + } } const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index fe9139308f61..b74b9d9c2e61 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -581,12 +581,19 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); } - runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-second"); + runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); const secondTurn = yield* adapter.sendTurn({ threadId, input: "second prompt", attachments: [], }); + config.onEvent?.({ + id: "user-message-second", + timestamp, + parentId: null, + type: "user.message", + data: { content: "second prompt" }, + } as SessionEvent); completeTurn("sdk-turn-second", "evt-second"); for ( let attempt = 0; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 7be901fb2324..99e7d815c439 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1185,6 +1185,24 @@ function resolveTurnIdForSdkTurn( return nextTurnId; } +function resolveTurnIdForSdkUserMessage( + context: CopilotSessionContext, + event: Extract, +): TurnId | undefined { + const mappedTurnId = context.turnIdByProviderItemId.get(event.id); + if (mappedTurnId) { + return mappedTurnId; + } + + return context.queuedTurnIds.find((turnId) => { + const snapshot = context.turns.find((turn) => turn.id === turnId); + return ( + snapshot?.sdkHistoryEventId === undefined && + !isSdkEventBeforeQueuedTurn(context, turnId, event.timestamp) + ); + }); +} + function resolveTurnIdForEvent( context: CopilotSessionContext, input?: { @@ -2401,6 +2419,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( await runWithContext(emitBackgroundTasksPlanSnapshot(context, event)); return; } + case "user.message": { + const turnId = resolveTurnIdForSdkUserMessage(context, event); + if (!turnId) { + return; + } + context.turnIdByProviderItemId.set(event.id, turnId); + ensureTurnSnapshot(context, turnId).sdkHistoryEventId = event.id; + return; + } case "assistant.turn_start": { await completePendingActiveTurnEnd(context); if (context.activeTurnId) { @@ -3416,9 +3443,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const nextLength = Math.max(0, context.turns.length - numTurns); const removedTurns = context.turns.slice(nextLength); - const sdkHistoryEventId = removedTurns.find( - (turn) => turn.sdkHistoryEventId, - )?.sdkHistoryEventId; + const sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + if (removedTurns.length > 0 && !sdkHistoryEventId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: "Cannot roll back Copilot history without the first removed user message ID.", + }); + } if (sdkHistoryEventId) { yield* copilotSdk.truncateHistory(context, sdkHistoryEventId); } diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 424a798d3ac2..16f4b6c9f5be 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -221,20 +221,18 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { }), ); - it.effect("keeps client-seeded thread titles without starting a Copilot SDK session", () => + it.effect("generates local thread title fallbacks without starting a Copilot SDK session", () => Effect.gen(function* () { const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); - const result = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "Investigate Copilot thread startup errors after reconnecting.", - modelSelection, - }) - .pipe(Effect.result); + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Investigate Copilot thread startup errors after reconnecting.", + modelSelection, + }); - expect(result._tag).toBe("Failure"); + expect(result.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 a5661ac90168..a5d4ea0d60e0 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -31,6 +31,7 @@ import { makeBranchNameGenerationResult, makeCommitMessageGenerationResult, makePrContentGenerationResult, + makeThreadTitleGenerationResult, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; @@ -100,6 +101,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, @@ -521,13 +532,9 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ); }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = () => - Effect.fail( - new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Copilot uses the client-seeded thread title.", - }), - ); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( + input, + ) => Effect.succeed(copilotThreadTitleFallback(input)); return { generateCommitMessage, From a66e39efbec482ddb29cfdd60875ccd529bbb5e3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 13:12:09 +0200 Subject: [PATCH 128/155] test(server): synchronize provider settings watcher --- .../src/provider/Layers/ProviderRegistry.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1a39d01176ca..e3f8dbb25f6f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -279,8 +280,9 @@ function makeMutableServerSettingsService( return Effect.gen(function* () { const settingsRef = yield* Ref.make(initial); const changes = yield* PubSub.unbounded(); + const changesSubscribed = yield* Deferred.make(); - return { + const service = { start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), @@ -294,9 +296,18 @@ function makeMutableServerSettingsService( return next; }), get streamChanges() { - return Stream.fromPubSub(changes); + return Stream.unwrap( + PubSub.subscribe(changes).pipe( + Effect.tap(() => Deferred.succeed(changesSubscribed, undefined)), + Effect.map(Stream.fromSubscription), + ), + ); }, } satisfies ServerSettingsModule.ServerSettingsService["Service"]; + + return Object.assign(service, { + changesSubscribed: Deferred.await(changesSubscribed), + }); }); } @@ -1228,6 +1239,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; + yield* serverSettings.changesSubscribed; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. From 40cfe7de033dc84fcf670b710f3d3ab0c954fceb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 13:29:15 +0200 Subject: [PATCH 129/155] fix(server): harden copilot rollback and settings watching --- .../provider/Layers/CopilotAdapter.test.ts | 75 ++++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 57 ++++++++++---- .../ProviderInstanceRegistryHydration.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 17 +++-- apps/server/src/serverSettings.ts | 45 +++++++---- 5 files changed, 160 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index b74b9d9c2e61..48fdd8ae505c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -76,6 +76,7 @@ const runtimeMock = vi.hoisted(() => { }, }, disconnect: vi.fn(async () => undefined), + getEvents: vi.fn(async (): Promise => []), setModel: vi.fn(async () => undefined), send: vi.fn(async (): Promise => undefined), abort: vi.fn(async () => undefined), @@ -587,6 +588,28 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { input: "second prompt", attachments: [], }); + completeTurn("sdk-turn-second", "evt-second"); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); + const thirdTurn = yield* adapter.sendTurn({ + threadId, + input: "third prompt", + attachments: [], + }); + // The second turn's SDK user event can arrive after that turn completed + // and after a new app turn was queued. It must bind to the oldest local + // turn missing a history boundary instead of hijacking the new queue. config.onEvent?.({ id: "user-message-second", timestamp, @@ -594,20 +617,27 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { type: "user.message", data: { content: "second prompt" }, } as SessionEvent); - completeTurn("sdk-turn-second", "evt-second"); + config.onEvent?.({ + id: "user-message-third", + timestamp, + parentId: null, + type: "user.message", + data: { content: "third prompt" }, + } as SessionEvent); + completeTurn("sdk-turn-third", "evt-third"); for ( let attempt = 0; attempt < 20 && !runtimeEvents.some( (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + event.type === "turn.completed" && String(event.turnId) === String(thirdTurn.turnId), ); attempt += 1 ) { yield* waitForSdkEventQueue(); } - const snapshot = yield* adapter.rollbackThread(threadId, 1); + const snapshot = yield* adapter.rollbackThread(threadId, 2); NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ [{ eventId: "user-message-second" }], ]); @@ -620,6 +650,45 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { [String(firstTurn.turnId)], ); + runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); + const recoveredTurn = yield* adapter.sendTurn({ + threadId, + input: "recover persisted boundary", + attachments: [], + }); + completeTurn("sdk-turn-recovered", "evt-recovered"); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && + String(event.turnId) === String(recoveredTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + runtimeMock.state.lastSession.getEvents.mockResolvedValueOnce([ + { + id: "user-message-recovered", + timestamp, + parentId: null, + type: "user.message", + data: { content: "recover persisted boundary" }, + } as SessionEvent, + ]); + + const recoveredSnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ + [{ eventId: "user-message-second" }], + [{ eventId: "user-message-recovered" }], + ]); + NodeAssert.deepStrictEqual( + recoveredSnapshot.turns.map((turn) => String(turn.id)), + [String(firstTurn.turnId)], + ); + 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 99e7d815c439..fcb1c7349b47 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1194,13 +1194,24 @@ function resolveTurnIdForSdkUserMessage( return mappedTurnId; } - return context.queuedTurnIds.find((turnId) => { - const snapshot = context.turns.find((turn) => turn.id === turnId); - return ( - snapshot?.sdkHistoryEventId === undefined && - !isSdkEventBeforeQueuedTurn(context, turnId, event.timestamp) - ); - }); + return context.turns.find( + (snapshot) => + snapshot.sdkHistoryEventId === undefined && + !isSdkEventBeforeQueuedTurn(context, snapshot.id, event.timestamp), + )?.id; +} + +function bindSdkUserMessageToTurn( + context: CopilotSessionContext, + event: Extract, +): TurnId | undefined { + const turnId = resolveTurnIdForSdkUserMessage(context, event); + if (!turnId) { + return undefined; + } + context.turnIdByProviderItemId.set(event.id, turnId); + ensureTurnSnapshot(context, turnId).sdkHistoryEventId = event.id; + return turnId; } function resolveTurnIdForEvent( @@ -1382,6 +1393,19 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( cause, }), }).pipe(Effect.asVoid), + getHistoryEvents: ( + context: CopilotSessionContext, + ): Effect.Effect, ProviderAdapterRequestError> => + Effect.tryPromise({ + try: () => context.sdkSession.getEvents(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.getEvents", + detail: detailFromCause(cause, "Failed to read Copilot history."), + cause, + }), + }), readPlan: ( context: CopilotSessionContext, ): Effect.Effect => @@ -2420,12 +2444,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } case "user.message": { - const turnId = resolveTurnIdForSdkUserMessage(context, event); - if (!turnId) { - return; - } - context.turnIdByProviderItemId.set(event.id, turnId); - ensureTurnSnapshot(context, turnId).sdkHistoryEventId = event.id; + bindSdkUserMessageToTurn(context, event); return; } case "assistant.turn_start": { @@ -3429,6 +3448,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* requireSessionContext(sessions, threadId); + yield* Effect.promise(() => context.eventChain); if ( context.activeTurnId !== undefined || context.activeSdkTurnKey !== undefined || @@ -3443,7 +3463,16 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const nextLength = Math.max(0, context.turns.length - numTurns); const removedTurns = context.turns.slice(nextLength); - const sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + let sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + if (removedTurns.length > 0 && !sdkHistoryEventId) { + const historyEvents = yield* copilotSdk.getHistoryEvents(context); + for (const event of historyEvents) { + if (event.type === "user.message") { + bindSdkUserMessageToTurn(context, event); + } + } + sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + } if (removedTurns.length > 0 && !sdkHistoryEventId) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a6..910f69c5e2a0 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -118,7 +118,11 @@ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; - yield* serverSettings.streamChanges.pipe( + // Acquire the PubSub subscription in this fiber before forking the + // consumer. `streamChanges` subscribes only when the forked stream starts, + // which leaves a window where an immediate settings publish is dropped. + const settingsChanges = yield* serverSettings.subscribeChanges; + yield* settingsChanges.pipe( Stream.runForEach((next) => mutator .reconcile(deriveProviderInstanceConfigMap(next)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e3f8dbb25f6f..312551dfb987 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -280,7 +280,7 @@ function makeMutableServerSettingsService( return Effect.gen(function* () { const settingsRef = yield* Ref.make(initial); const changes = yield* PubSub.unbounded(); - const changesSubscribed = yield* Deferred.make(); + const settingsWatcherSubscribed = yield* Deferred.make(); const service = { start: Effect.void, @@ -296,17 +296,18 @@ function makeMutableServerSettingsService( return next; }), get streamChanges() { - return Stream.unwrap( - PubSub.subscribe(changes).pipe( - Effect.tap(() => Deferred.succeed(changesSubscribed, undefined)), - Effect.map(Stream.fromSubscription), - ), + return Stream.fromPubSub(changes); + }, + get subscribeChanges() { + return PubSub.subscribe(changes).pipe( + Effect.tap(() => Deferred.succeed(settingsWatcherSubscribed, undefined)), + Effect.map(Stream.fromSubscription), ); }, } satisfies ServerSettingsModule.ServerSettingsService["Service"]; return Object.assign(service, { - changesSubscribed: Deferred.await(changesSubscribed), + settingsWatcherSubscribed: Deferred.await(settingsWatcherSubscribed), }); }); } @@ -1239,7 +1240,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - yield* serverSettings.changesSubscribed; + yield* serverSettings.settingsWatcherSubscribed; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640fe..2b928b00d502 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -127,6 +127,14 @@ export class ServerSettingsService extends Context.Service< /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; + + /** + * Acquire the settings change subscription synchronously and return a + * stream backed by it. Consumers that fork a long-running watcher should + * use this instead of `streamChanges` so a publish cannot land between + * scheduling the watcher fiber and the fiber subscribing. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { /** @deprecated Import and use `layerTest` from this module. */ @@ -156,6 +164,7 @@ const makeTest = (overrides: DeepPartial = {}) => Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; }); @@ -556,6 +565,23 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); + const resolveChangedSettings = (changes: Stream.Stream) => + changes.pipe( + Stream.mapEffect((settings) => + materializeProviderEnvironmentSecrets(settings).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(settings)), + ), + ), + ), + Stream.map(resolveTextGenerationProvider), + ); + return { start, ready: Deferred.await(startedDeferred), @@ -580,20 +606,11 @@ const make = Effect.gen(function* () { }), ), get streamChanges() { - return Stream.fromPubSub(changesPubSub).pipe( - Stream.mapEffect((settings) => - materializeProviderEnvironmentSecrets(settings).pipe( - Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }).pipe(Effect.as(settings)), - ), - ), - ), - Stream.map(resolveTextGenerationProvider), + return resolveChangedSettings(Stream.fromPubSub(changesPubSub)); + }, + get subscribeChanges() { + return PubSub.subscribe(changesPubSub).pipe( + Effect.map((subscription) => resolveChangedSettings(Stream.fromSubscription(subscription))), ); }, } satisfies ServerSettingsService["Service"]; From b0631f9992065bb592b0b60100056cc681a918df Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 13:54:25 +0200 Subject: [PATCH 130/155] fix(copilot): serialize rollback history mutations --- .../provider/Layers/CopilotAdapter.test.ts | 121 +++++++ .../src/provider/Layers/CopilotAdapter.ts | 342 ++++++++++-------- 2 files changed, 309 insertions(+), 154 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 48fdd8ae505c..a0fb27d5ae4b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -694,6 +694,127 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("serializes rollback with SDK events and new sends", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-rollback-serialization"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + config.onEvent?.({ + id: "rollback-serialization-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-serialization-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-serialization-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(); + } + + let markHistoryReadStarted!: () => void; + const historyReadStarted = new Promise((resolve) => { + markHistoryReadStarted = resolve; + }); + let resolveHistoryEvents!: (events: SessionEvent[]) => void; + const historyEvents = new Promise((resolve) => { + resolveHistoryEvents = resolve; + }); + runtimeMock.state.lastSession.getEvents.mockImplementationOnce(async () => { + markHistoryReadStarted(); + return historyEvents; + }); + + const rollbackFiber = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.forkChild); + yield* Effect.promise(() => historyReadStarted); + + runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-new"); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "new prompt", + attachments: [], + }) + .pipe(Effect.forkChild); + config.onEvent?.({ + id: "late-user-message", + timestamp, + parentId: null, + type: "user.message", + data: { content: "late event during rollback" }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + NodeAssert.equal(runtimeMock.state.lastSession.send.mock.calls.length, 1); + resolveHistoryEvents([ + { + id: "user-message-first", + timestamp, + parentId: null, + type: "user.message", + data: { content: "first prompt" }, + } as SessionEvent, + ]); + + const rolledBack = yield* Fiber.join(rollbackFiber); + const newTurn = yield* Fiber.join(sendFiber); + NodeAssert.deepStrictEqual(rolledBack.turns, []); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ + [{ eventId: "user-message-first" }], + ]); + NodeAssert.deepStrictEqual( + (yield* adapter.readThread(threadId)).turns.map((turn) => String(turn.id)), + [String(newTurn.turnId)], + ); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("keeps assistant call usage on the turn without publishing context usage", () => 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 fcb1c7349b47..9bfc5aa06acd 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -40,6 +40,7 @@ import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as PubSub from "effect/PubSub"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -239,6 +240,7 @@ interface CopilotSessionContext { activeTurnId: TurnId | undefined; activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; + readonly historyMutationSemaphore: Semaphore.Semaphore; eventChain: Promise; stopped: boolean; } @@ -3025,6 +3027,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }).pipe( Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), ); + const historyMutationSemaphore = yield* Semaphore.make(1); context = { threadId: input.threadId, @@ -3071,6 +3074,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( activeTurnId: undefined, activeSdkTurnId: undefined, activeSdkTurnKey: undefined, + historyMutationSemaphore, eventChain: Promise.resolve(), stopped: false, }; @@ -3161,99 +3165,106 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); yield* syncSessionMode(context, mode); - const queuedAt = yield* DateTime.now; - ensureTurnSnapshot(context, turnId); - context.turnStartPayloadByTurnId.set(turnId, { - model: modelSelection?.model ?? context.session.model, - effort: reasoningEffort, - contextTier, - }); - context.turnQueuedAtMsByTurnId.set(turnId, epochMsFromIso(DateTime.formatIso(queuedAt)) ?? 0); - context.queuedTurnIds.push(turnId); - yield* Effect.promise(async () => { - context.eventChain = context.eventChain.then(async () => { - await completePendingActiveTurnEnd(context); - }); - await context.eventChain; - }); - const shouldPromoteQueuedTurn = - context.activeTurnId === undefined && context.activeSdkTurnKey === undefined; - if (shouldPromoteQueuedTurn) { - context.activeTurnId = turnId; - } - updateProviderSession(context, { - status: "running", - ...(shouldPromoteQueuedTurn ? { activeTurnId: turnId } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), - }); + return yield* context.historyMutationSemaphore.withPermit( + Effect.gen(function* () { + const queuedAt = yield* DateTime.now; + ensureTurnSnapshot(context, turnId); + context.turnStartPayloadByTurnId.set(turnId, { + model: modelSelection?.model ?? context.session.model, + effort: reasoningEffort, + contextTier, + }); + context.turnQueuedAtMsByTurnId.set( + turnId, + epochMsFromIso(DateTime.formatIso(queuedAt)) ?? 0, + ); + context.queuedTurnIds.push(turnId); + yield* Effect.promise(async () => { + context.eventChain = context.eventChain.then(async () => { + await completePendingActiveTurnEnd(context); + }); + await context.eventChain; + }); + const shouldPromoteQueuedTurn = + context.activeTurnId === undefined && context.activeSdkTurnKey === undefined; + if (shouldPromoteQueuedTurn) { + context.activeTurnId = turnId; + } + updateProviderSession(context, { + status: "running", + ...(shouldPromoteQueuedTurn ? { activeTurnId: turnId } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + }); - if (shouldPromoteQueuedTurn) { - yield* emitTurnStarted(context, turnId); - } + if (shouldPromoteQueuedTurn) { + yield* emitTurnStarted(context, turnId); + } - const messageOptions: MessageOptions = { - prompt: text ?? "", - ...(attachments.length > 0 ? { attachments } : {}), - mode: "enqueue", - }; + const messageOptions: MessageOptions = { + prompt: text ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "enqueue", + }; - const providerMessageId = yield* copilotSdk.send(context, messageOptions).pipe( - Effect.catch((error) => - Effect.gen(function* () { - const queueIndex = context.queuedTurnIds.indexOf(turnId); - if (queueIndex >= 0) { - context.queuedTurnIds.splice(queueIndex, 1); - } - context.turnQueuedAtMsByTurnId.delete(turnId); - context.turnStartPayloadByTurnId.delete(turnId); - if (context.activeTurnId === turnId) { - context.activeTurnId = undefined; - } - updateProviderSession(context, { - status: context.activeTurnId ? "running" : readyStatusAfterTurnCompletion(context), - ...(context.activeTurnId - ? { activeTurnId: context.activeTurnId } - : { activeTurnId: undefined }), - }); - yield* emit({ - ...createBaseEvent({ - threadId: input.threadId, - turnId, + 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); + context.turnStartPayloadByTurnId.delete(turnId); + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + } + updateProviderSession(context, { + status: context.activeTurnId ? "running" : readyStatusAfterTurnCompletion(context), + ...(context.activeTurnId + ? { activeTurnId: context.activeTurnId } + : { activeTurnId: undefined }), + }); + yield* emit({ + ...createBaseEvent({ + threadId: input.threadId, + turnId, + }), + type: "turn.aborted", + payload: { + reason: error.detail, + }, + }); + yield* Effect.tryPromise({ + try: () => + emitTurnCompleted(context, turnId, "failed", { + errorMessage: error.detail, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: detailFromCause(cause, "Failed to emit Copilot turn completion."), + cause, + }), + }); + return yield* error; }), - type: "turn.aborted", - payload: { - reason: error.detail, - }, - }); - yield* Effect.tryPromise({ - try: () => - emitTurnCompleted(context, turnId, "failed", { - errorMessage: error.detail, - }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: detailFromCause(cause, "Failed to emit Copilot turn completion."), - cause, - }), - }); - return yield* error; - }), - ), - ); - if (trimOrUndefined(providerMessageId)) { - context.turnIdByProviderItemId.set(providerMessageId, turnId); - ensureTurnSnapshot(context, turnId).sdkHistoryEventId = providerMessageId; - } + ), + ); + if (trimOrUndefined(providerMessageId)) { + context.turnIdByProviderItemId.set(providerMessageId, turnId); + ensureTurnSnapshot(context, turnId).sdkHistoryEventId = providerMessageId; + } - return { - threadId: input.threadId, - turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; + return { + threadId: input.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }), + ); }); const interruptTurn: CopilotAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( @@ -3448,74 +3459,97 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const rollbackThread: CopilotAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* requireSessionContext(sessions, threadId); - yield* Effect.promise(() => context.eventChain); - if ( - context.activeTurnId !== undefined || - context.activeSdkTurnKey !== undefined || - context.queuedTurnIds.length > 0 - ) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread.rollback", - detail: "Cannot roll back Copilot history while a turn is active or queued.", - }); - } + return yield* context.historyMutationSemaphore.withPermit( + Effect.acquireUseRelease( + Effect.sync(() => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const pendingEvents = context.eventChain; + context.eventChain = pendingEvents.then(() => gate); + return { pendingEvents, release }; + }), + (eventChainGate) => + Effect.gen(function* () { + yield* Effect.promise(() => eventChainGate.pendingEvents); + if ( + context.activeTurnId !== undefined || + context.activeSdkTurnKey !== undefined || + context.queuedTurnIds.length > 0 + ) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: "Cannot roll back Copilot history while a turn is active or queued.", + }); + } - const nextLength = Math.max(0, context.turns.length - numTurns); - const removedTurns = context.turns.slice(nextLength); - let sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; - if (removedTurns.length > 0 && !sdkHistoryEventId) { - const historyEvents = yield* copilotSdk.getHistoryEvents(context); - for (const event of historyEvents) { - if (event.type === "user.message") { - bindSdkUserMessageToTurn(context, event); - } - } - sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; - } - if (removedTurns.length > 0 && !sdkHistoryEventId) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread.rollback", - detail: "Cannot roll back Copilot history without the first removed user message ID.", - }); - } - if (sdkHistoryEventId) { - yield* copilotSdk.truncateHistory(context, sdkHistoryEventId); - } - - context.turns.splice(nextLength); - const removedTurnIds = new Set(removedTurns.map((turn) => turn.id)); - for (const turnId of removedTurnIds) { - context.turnQueuedAtMsByTurnId.delete(turnId); - context.completedTurnIds.delete(turnId); - context.emittedTurnStartedIds.delete(turnId); - context.turnStartPayloadByTurnId.delete(turnId); - context.turnUsageByTurnId.delete(turnId); - context.assistantItemIdByTurnId.delete(turnId); - context.pendingTaskCompletionTextByTurnId.delete(turnId); - context.emittedTurnDiffByTurnId.delete(turnId); - context.turnIdsWithAssistantText.delete(turnId); - context.turnEndEventsByTurnId.delete(turnId); - clearSdkTurnMappingsForTurn(context, turnId); - } - for (const [providerItemId, turnId] of context.turnIdByProviderItemId) { - if (!removedTurnIds.has(turnId)) { - continue; - } - context.turnIdByProviderItemId.delete(providerItemId); - context.emittedTextByItemId.delete(providerItemId); - context.toolMetaById.delete(providerItemId); - context.startedItemIds.delete(providerItemId); - } + const nextLength = Math.max(0, context.turns.length - numTurns); + const removedTurns = context.turns.slice(nextLength); + let sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + if (removedTurns.length > 0 && !sdkHistoryEventId) { + const historyEvents = yield* copilotSdk.getHistoryEvents(context); + for (const event of historyEvents) { + if (event.type === "user.message") { + bindSdkUserMessageToTurn(context, event); + } + } + sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + } + if (removedTurns.length > 0 && !sdkHistoryEventId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: + "Cannot roll back Copilot history without the first removed user message ID.", + }); + } + if (sdkHistoryEventId) { + yield* copilotSdk.truncateHistory(context, sdkHistoryEventId); + } - return { - threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), - }; + context.turns.splice(nextLength); + const removedTurnIds = new Set(removedTurns.map((turn) => turn.id)); + for (const turnId of removedTurnIds) { + context.turnQueuedAtMsByTurnId.delete(turnId); + context.completedTurnIds.delete(turnId); + context.emittedTurnStartedIds.delete(turnId); + context.turnStartPayloadByTurnId.delete(turnId); + context.turnUsageByTurnId.delete(turnId); + context.assistantItemIdByTurnId.delete(turnId); + context.pendingTaskCompletionTextByTurnId.delete(turnId); + context.emittedTurnDiffByTurnId.delete(turnId); + context.turnIdsWithAssistantText.delete(turnId); + context.turnEndEventsByTurnId.delete(turnId); + clearSdkTurnMappingsForTurn(context, turnId); + } + for (const [providerItemId, turnId] of context.turnIdByProviderItemId) { + if (!removedTurnIds.has(turnId)) { + continue; + } + context.turnIdByProviderItemId.delete(providerItemId); + context.emittedTextByItemId.delete(providerItemId); + context.toolMetaById.delete(providerItemId); + context.startedItemIds.delete(providerItemId); + } + + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }), + (eventChainGate) => + Effect.promise(async () => { + const gatedEvents = context.eventChain; + eventChainGate.release(); + await gatedEvents; + }), + ), + ); }, ); From b9ef0006cc2af29b6af90aff11d97a114ed985f7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:10:30 +0200 Subject: [PATCH 131/155] fix(copilot): harden rollback shutdown ordering --- .../provider/Layers/CopilotAdapter.test.ts | 161 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 98 ++++++----- 2 files changed, 209 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index a0fb27d5ae4b..147017912634 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -689,6 +689,37 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { [String(firstTurn.turnId)], ); + runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); + const turnWithoutHistoryBoundary = yield* adapter.sendTurn({ + threadId, + input: "missing persisted boundary", + attachments: [], + }); + completeTurn("sdk-turn-without-boundary", "evt-without-boundary"); + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "turn.completed" && + String(event.turnId) === String(turnWithoutHistoryBoundary.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + + const missingBoundaryError = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + NodeAssert.match(missingBoundaryError.message, /without the first removed user message ID/); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ + [{ eventId: "user-message-second" }], + [{ eventId: "user-message-recovered" }], + ]); + NodeAssert.deepStrictEqual( + (yield* adapter.readThread(threadId)).turns.map((turn) => String(turn.id)), + [String(firstTurn.turnId), String(turnWithoutHistoryBoundary.turnId)], + ); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); yield* adapter.stopSession(threadId); }), @@ -815,6 +846,136 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("lets rollback finish before gated events drain while stop waits for both", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-rollback-stop-serialization"); + + 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(); + + runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-first"); + const turn = yield* adapter.sendTurn({ + threadId, + input: "first prompt", + attachments: [], + }); + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent?.({ + id: "rollback-stop-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-stop-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-stop-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(); + } + + let markTruncateStarted!: () => void; + const truncateStarted = new Promise((resolve) => { + markTruncateStarted = resolve; + }); + let releaseTruncate!: () => void; + const truncateGate = new Promise((resolve) => { + releaseTruncate = resolve; + }); + runtimeMock.state.lastSession.rpc.history.truncate.mockImplementationOnce(async () => { + markTruncateStarted(); + await truncateGate; + return { removedEventCount: 1 }; + }); + + let markRollbackFinished!: () => void; + const rollbackFinished = new Promise((resolve) => { + markRollbackFinished = resolve; + }); + const rollbackFiber = yield* adapter.rollbackThread(threadId, 1).pipe( + Effect.tap(() => Effect.sync(markRollbackFinished)), + Effect.forkChild, + ); + yield* Effect.promise(() => truncateStarted); + + let releaseNativeWrite: () => void = () => undefined; + runtimeMock.state.nativeWriteGate = new Promise((resolve) => { + releaseNativeWrite = resolve; + }); + config.onEvent?.({ + id: "late-user-message-after-rollback", + timestamp, + parentId: null, + type: "user.message", + data: { content: "late SDK event" }, + } as SessionEvent); + releaseTruncate(); + + for ( + let attempt = 0; + attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + const rollbackFinishedBeforeEventDrain = yield* Effect.promise(() => + Promise.race([ + rollbackFinished.then(() => true), + NodeTimersPromises.setTimeout(200).then(() => false), + ]), + ); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* waitForSdkEventQueue(); + const disconnectsBeforeEventDrain = + runtimeMock.state.lastSession.disconnect.mock.calls.length; + + releaseNativeWrite(); + const rolledBack = yield* Fiber.join(rollbackFiber); + yield* Fiber.join(stopFiber); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(rollbackFinishedBeforeEventDrain, true); + NodeAssert.deepStrictEqual(rolledBack.turns, []); + NodeAssert.equal(disconnectsBeforeEventDrain, 0); + NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); + NodeAssert.equal(runtimeMock.state.stopCalls, 1); + }), + ); + it.effect("keeps assistant call usage on the turn without publishing context usage", () => 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 9bfc5aa06acd..33afd22ea956 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3373,56 +3373,59 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } if (context.stopped) { - sessions.delete(threadId); return; } context.stopped = true; - yield* Effect.promise(() => context.eventChain); - const activeTurnId = context.activeTurnId; - if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { - yield* Effect.promise(() => - 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), - ); - yield* settlePendingUserInputs(context); - yield* copilotSdk.disconnect(context).pipe(Effect.ignore); - yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + yield* context.historyMutationSemaphore.withPermit( + Effect.gen(function* () { + yield* Effect.promise(() => context.eventChain); + const activeTurnId = context.activeTurnId; + if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { + yield* Effect.promise(() => + 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), + ); + 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, + 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); }), - type: "session.exited", - payload: { - reason: "Copilot session stopped.", - exitKind: "graceful", - }, - }); - sessions.delete(threadId); + ); }); const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( @@ -3542,12 +3545,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( })), }; }), - (eventChainGate) => - Effect.promise(async () => { - const gatedEvents = context.eventChain; - eventChainGate.release(); - await gatedEvents; - }), + (eventChainGate) => Effect.sync(eventChainGate.release), ), ); }, From 040030b179ebc7a891ebacc85aca748e68db6b26 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:24:16 +0200 Subject: [PATCH 132/155] fix(copilot): serialize session replacement teardown --- .../provider/Layers/CopilotAdapter.test.ts | 15 +++ .../src/provider/Layers/CopilotAdapter.ts | 108 ++++++++++-------- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 147017912634..6f70051a1499 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -5277,10 +5277,21 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); } + const replacementFiber = yield* adapter + .startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* waitForSdkEventQueue(); + const sessionCreationsBeforeDrain = runtimeMock.state.createSessionConfigs.length; const disconnectsBeforeDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; releaseNativeWrite(); yield* TestClock.adjust("25 millis"); yield* Fiber.join(stopFiber); + yield* Fiber.join(replacementFiber); for ( let attempt = 0; @@ -5292,9 +5303,11 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); NodeAssert.equal(runtimeMock.state.nativeWriteCalls > 0, true); + NodeAssert.equal(sessionCreationsBeforeDrain, 1); NodeAssert.equal(disconnectsBeforeDrain, 0); NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), true); const completed = runtimeEvents.find((event) => event.type === "turn.completed"); NodeAssert.equal(completed?.type, "turn.completed"); @@ -5302,6 +5315,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { NodeAssert.equal(String(completed.turnId), String(turn.turnId)); NodeAssert.equal(completed.payload.state, "interrupted"); } + + yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 33afd22ea956..be0bf84645ad 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -241,6 +241,7 @@ interface CopilotSessionContext { activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; readonly historyMutationSemaphore: Semaphore.Semaphore; + readonly stopCompletion: Deferred.Deferred; eventChain: Promise; stopped: boolean; } @@ -3028,6 +3029,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), ); const historyMutationSemaphore = yield* Semaphore.make(1); + const stopCompletion = yield* Deferred.make(); context = { threadId: input.threadId, @@ -3075,6 +3077,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( activeSdkTurnId: undefined, activeSdkTurnKey: undefined, historyMutationSemaphore, + stopCompletion, eventChain: Promise.resolve(), stopped: false, }; @@ -3373,59 +3376,70 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } if (context.stopped) { + yield* Deferred.await(context.stopCompletion); return; } context.stopped = true; - yield* context.historyMutationSemaphore.withPermit( - Effect.gen(function* () { - yield* Effect.promise(() => context.eventChain); - const activeTurnId = context.activeTurnId; - if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { - yield* Effect.promise(() => - 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), - ); - yield* settlePendingUserInputs(context); - yield* copilotSdk.disconnect(context).pipe(Effect.ignore); - yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + yield* context.historyMutationSemaphore + .withPermit( + Effect.gen(function* () { + yield* Effect.promise(() => context.eventChain); + const activeTurnId = context.activeTurnId; + if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { + yield* Effect.promise(() => + 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), + ); + 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, + 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", + }, + }); + }), + ) + .pipe( + Effect.ensuring( + Effect.gen(function* () { + if (sessions.get(threadId) === context) { + sessions.delete(threadId); + } + yield* Deferred.succeed(context.stopCompletion, undefined); }), - type: "session.exited", - payload: { - reason: "Copilot session stopped.", - exitKind: "graceful", - }, - }); - sessions.delete(threadId); - }), - ); + ), + ); }); const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( From 79f94eb52645c24ab95bddcb01eeea483ebf7e62 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:38:31 +0200 Subject: [PATCH 133/155] fix(copilot): serialize per-thread session lifecycle --- .../provider/Layers/CopilotAdapter.test.ts | 47 ++++- .../src/provider/Layers/CopilotAdapter.ts | 167 ++++++++++-------- 2 files changed, 131 insertions(+), 83 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 6f70051a1499..7611f1e16295 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -846,7 +846,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("lets rollback finish before gated events drain while stop waits for both", () => + it.effect("retries interrupted stop before replacing a session", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-rollback-stop-serialization"); @@ -932,6 +932,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Effect.promise(() => truncateStarted); let releaseNativeWrite: () => void = () => undefined; + const nativeWritesBeforeLateEvent = runtimeMock.state.nativeWriteCalls; runtimeMock.state.nativeWriteGate = new Promise((resolve) => { releaseNativeWrite = resolve; }); @@ -942,11 +943,18 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { type: "user.message", data: { content: "late SDK event" }, } as SessionEvent); - releaseTruncate(); + const interruptedStopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(interruptedStopFiber).pipe(Effect.ignore); + const sessionPresentAfterInterruptedStop = yield* adapter.hasSession(threadId); + const disconnectsAfterInterruptedStop = + runtimeMock.state.lastSession.disconnect.mock.calls.length; + + releaseTruncate(); for ( let attempt = 0; - attempt < 20 && runtimeMock.state.nativeWriteCalls === 0; + attempt < 20 && runtimeMock.state.nativeWriteCalls === nativeWritesBeforeLateEvent; attempt += 1 ) { yield* waitForSdkEventQueue(); @@ -958,21 +966,35 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ]), ); - const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + const replacementFiber = yield* adapter + .startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); yield* waitForSdkEventQueue(); + const sessionCreationsBeforeEventDrain = runtimeMock.state.createSessionConfigs.length; const disconnectsBeforeEventDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; releaseNativeWrite(); const rolledBack = yield* Fiber.join(rollbackFiber); - yield* Fiber.join(stopFiber); + yield* Fiber.join(replacementFiber); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); NodeAssert.equal(rollbackFinishedBeforeEventDrain, true); NodeAssert.deepStrictEqual(rolledBack.turns, []); + NodeAssert.equal(sessionPresentAfterInterruptedStop, true); + NodeAssert.equal(disconnectsAfterInterruptedStop, 0); + NodeAssert.equal(sessionCreationsBeforeEventDrain, 1); NodeAssert.equal(disconnectsBeforeEventDrain, 0); NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), true); + + yield* adapter.stopSession(threadId); }), ); @@ -5285,6 +5307,14 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }) .pipe(Effect.forkChild); + const secondReplacementFiber = yield* adapter + .startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); yield* waitForSdkEventQueue(); const sessionCreationsBeforeDrain = runtimeMock.state.createSessionConfigs.length; const disconnectsBeforeDrain = runtimeMock.state.lastSession.disconnect.mock.calls.length; @@ -5292,6 +5322,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* TestClock.adjust("25 millis"); yield* Fiber.join(stopFiber); yield* Fiber.join(replacementFiber); + yield* Fiber.join(secondReplacementFiber); for ( let attempt = 0; @@ -5305,8 +5336,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { NodeAssert.equal(runtimeMock.state.nativeWriteCalls > 0, true); NodeAssert.equal(sessionCreationsBeforeDrain, 1); NodeAssert.equal(disconnectsBeforeDrain, 0); - NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); - NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(runtimeMock.state.createSessionConfigs.length, 3); + NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 2); + NodeAssert.equal(runtimeMock.state.startCalls, 3); + NodeAssert.equal(runtimeMock.state.stopCalls, 2); NodeAssert.equal(yield* adapter.hasSession(threadId), true); const completed = runtimeEvents.find((event) => event.type === "turn.completed"); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index be0bf84645ad..89f77e0e579f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -42,6 +42,7 @@ import * as Predicate from "effect/Predicate"; import * as PubSub from "effect/PubSub"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; @@ -241,7 +242,6 @@ interface CopilotSessionContext { activeSdkTurnId: string | undefined; activeSdkTurnKey: string | undefined; readonly historyMutationSemaphore: Semaphore.Semaphore; - readonly stopCompletion: Deferred.Deferred; eventChain: Promise; stopped: boolean; } @@ -1270,6 +1270,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const sessions = new Map(); + const lifecycleLocksRef = yield* SynchronizedRef.make(new Map()); const path = yield* Path.Path; const runtimeContext = yield* Effect.context(); const runWithContext = Effect.runPromiseWith(runtimeContext); @@ -1281,6 +1282,22 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const emit = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const getLifecycleSemaphore = (threadId: ThreadId) => + SynchronizedRef.modifyEffect(lifecycleLocksRef, (current) => { + const existing = current.get(threadId); + if (existing) { + return Effect.succeed([existing, current] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ); + }); + const withLifecycleLock = (threadId: ThreadId, effect: Effect.Effect) => + Effect.flatMap(getLifecycleSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); const emitTurnStarted = ( context: CopilotSessionContext, @@ -3029,7 +3046,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), ); const historyMutationSemaphore = yield* Semaphore.make(1); - const stopCompletion = yield* Deferred.make(); context = { threadId: input.threadId, @@ -3077,7 +3093,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( activeSdkTurnId: undefined, activeSdkTurnKey: undefined, historyMutationSemaphore, - stopCompletion, eventChain: Promise.resolve(), stopped: false, }; @@ -3112,6 +3127,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, ); + const startSessionWithLifecycleLock: CopilotAdapterShape["startSession"] = (input) => + withLifecycleLock(input.threadId, startSession(input)); + const sendTurn: CopilotAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSessionContext(sessions, input.threadId); @@ -3375,82 +3393,75 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!context) { return; } - if (context.stopped) { - yield* Deferred.await(context.stopCompletion); - return; - } context.stopped = true; - yield* context.historyMutationSemaphore - .withPermit( - Effect.gen(function* () { - yield* Effect.promise(() => context.eventChain); - const activeTurnId = context.activeTurnId; - if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { - yield* Effect.promise(() => - 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), - ); - 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, + yield* context.historyMutationSemaphore.withPermit( + Effect.gen(function* () { + yield* Effect.promise(() => context.eventChain); + const activeTurnId = context.activeTurnId; + if (activeTurnId && !context.completedTurnIds.has(activeTurnId)) { + yield* Effect.promise(() => + emitPendingTaskCompletionAsAssistantMessage(context, activeTurnId), + ).pipe(Effect.ignore); + yield* Effect.promise(() => + emitTurnCompleted(context, activeTurnId, "interrupted", { + stopReason: null, }), - type: "session.exited", - payload: { - reason: "Copilot session stopped.", - exitKind: "graceful", - }, - }); - }), - ) - .pipe( - Effect.ensuring( - Effect.gen(function* () { - if (sessions.get(threadId) === context) { - sessions.delete(threadId); - } - yield* Deferred.succeed(context.stopCompletion, undefined); + ).pipe(Effect.ignore); + } + cancelTurnEndFallback(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); + + 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", + }, + }); + if (sessions.get(threadId) === context) { + sessions.delete(threadId); + } + }), + ); }); const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { - if (!sessions.has(threadId)) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - yield* stopSessionInternal(threadId); + yield* withLifecycleLock( + threadId, + Effect.gen(function* () { + if (!sessions.has(threadId)) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + yield* stopSessionInternal(threadId); + }), + ); }, ); @@ -3567,10 +3578,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const stopAll: CopilotAdapterShape["stopAll"] = () => Effect.gen(function* () { - yield* Effect.forEach(Array.from(sessions.keys()), stopSessionInternal, { - concurrency: "unbounded", - discard: true, - }); + yield* Effect.forEach( + Array.from(sessions.keys()), + (threadId) => withLifecycleLock(threadId, stopSessionInternal(threadId)), + { + concurrency: "unbounded", + discard: true, + }, + ); if (managedNativeEventLogger) { yield* managedNativeEventLogger.close(); } @@ -3581,7 +3596,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( capabilities: { sessionModelSwitch: "in-session", }, - startSession, + startSession: startSessionWithLifecycleLock, sendTurn, interruptTurn, respondToRequest, From 4740f2faf18df95cdcbc2daf546ce41bfce22813 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:45:26 +0200 Subject: [PATCH 134/155] fix(copilot): coalesce concurrent session stops --- .../src/provider/Layers/CopilotAdapter.test.ts | 2 ++ apps/server/src/provider/Layers/CopilotAdapter.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 7611f1e16295..9e56eb4025f2 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -5298,6 +5298,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ) { yield* waitForSdkEventQueue(); } + const coalescedStopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); const replacementFiber = yield* adapter .startSession({ @@ -5321,6 +5322,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { releaseNativeWrite(); yield* TestClock.adjust("25 millis"); yield* Fiber.join(stopFiber); + yield* Fiber.join(coalescedStopFiber); yield* Fiber.join(replacementFiber); yield* Fiber.join(secondReplacementFiber); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 89f77e0e579f..0702529154f9 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3450,14 +3450,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } yield* withLifecycleLock( threadId, Effect.gen(function* () { - if (!sessions.has(threadId)) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); + if (sessions.get(threadId) !== context) { + return; } yield* stopSessionInternal(threadId); }), From ba8d61a14e63c632014c4b30f60602d45fda74ed Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:52:40 +0200 Subject: [PATCH 135/155] fix(copilot): stop the current serialized session --- .../provider/Layers/CopilotAdapter.test.ts | 47 +++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 5 +- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 9e56eb4025f2..9e7c357dff9b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -998,6 +998,53 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("stops a replacement that wins the lifecycle lock before stop", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-after-replacement"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + let markDisconnectStarted!: () => void; + const disconnectStarted = new Promise((resolve) => { + markDisconnectStarted = resolve; + }); + let releaseDisconnect!: () => void; + const disconnectGate = new Promise((resolve) => { + releaseDisconnect = resolve; + }); + runtimeMock.state.lastSession.disconnect.mockImplementationOnce(async () => { + markDisconnectStarted(); + await disconnectGate; + }); + + const replacementFiber = yield* adapter + .startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => disconnectStarted); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + releaseDisconnect(); + yield* Fiber.join(replacementFiber); + yield* Fiber.join(stopFiber); + + NodeAssert.equal(runtimeMock.state.startCalls, 2); + NodeAssert.equal(runtimeMock.state.stopCalls, 2); + NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 2); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("keeps assistant call usage on the turn without publishing context usage", () => 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 0702529154f9..c30018fdb368 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3450,8 +3450,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const stopSession: CopilotAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { - const context = sessions.get(threadId); - if (!context) { + if (!sessions.has(threadId)) { return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId, @@ -3460,7 +3459,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( yield* withLifecycleLock( threadId, Effect.gen(function* () { - if (sessions.get(threadId) !== context) { + if (!sessions.has(threadId)) { return; } yield* stopSessionInternal(threadId); From 29ab19daff836ff52fb9ecdfe4abb5442725964b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Mon, 13 Jul 2026 14:59:40 +0200 Subject: [PATCH 136/155] test(cursor): wait for ACP request log writes --- apps/server/src/provider/Layers/CursorAdapter.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a9776..ebf55ea3f57d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -2,6 +2,7 @@ import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeFSP from "node:fs/promises"; +import * as NodeTimersPromises from "node:timers/promises"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -116,7 +117,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { function waitForJsonLogMatch( filePath: string, predicate: (entry: Record) => boolean, - attempts = 40, + attempts = 100, ) { return Effect.gen(function* () { for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -124,7 +125,7 @@ function waitForJsonLogMatch( if (requests.some(predicate)) { return requests; } - yield* Effect.yieldNow; + yield* Effect.promise(() => NodeTimersPromises.setTimeout(10)); } return yield* Effect.promise(() => readJsonLines(filePath)); }); From c612c3f268705cf8f179869c9eb2b8a6f54ff4d3 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 13:38:41 +0200 Subject: [PATCH 137/155] fix(copilot): harden provider lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 1511 +++++++++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 583 +++++-- .../provider/Layers/CopilotProvider.test.ts | 28 +- .../src/provider/Layers/CopilotProvider.ts | 10 +- .../Layers/CopilotToolClassification.ts | 4 - .../src/provider/copilotMcpBearerAuth.ts | 43 + .../src/provider/copilotRuntime.test.ts | 89 +- apps/server/src/provider/copilotRuntime.ts | 138 +- .../src/provider/threadLifecycleLock.ts | 61 + .../CopilotTextGeneration.test.ts | 106 +- .../textGeneration/CopilotTextGeneration.ts | 117 +- 11 files changed, 2248 insertions(+), 442 deletions(-) create mode 100644 apps/server/src/provider/copilotMcpBearerAuth.ts create mode 100644 apps/server/src/provider/threadLifecycleLock.ts diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 9e7c357dff9b..7c13d8e9090f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { CopilotClient, CopilotSession, + MessageOptions, PermissionRequest, SessionConfig, SessionEvent, @@ -23,18 +24,22 @@ import { vi } from "vite-plus/test"; import { ApprovalRequestId, CopilotSettings, + EnvironmentId, type ProviderRuntimeEvent, ProviderDriverKind, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { makeCopilotAdapter } from "./CopilotAdapter.ts"; const decodeCopilotSettings = Schema.decodeSync(CopilotSettings); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); class CopilotAdapter extends Context.Service()( "t3/provider/Layers/CopilotAdapter.test/CopilotAdapter", @@ -49,25 +54,18 @@ const waitForSdkEventQueue = () => const runtimeMock = vi.hoisted(() => { const makeSession = () => ({ - tasks: { - list: vi.fn( - async (): Promise<{ tasks: Array> }> => ({ - tasks: [], - }), - ), - }, sessionId: "copilot-sdk-session-1", rpc: { mode: { set: vi.fn(async () => undefined), }, history: { - truncate: vi.fn(async () => ({ removedEventCount: 0 })), + truncate: vi.fn(async () => ({ eventsRemoved: 0 })), }, plan: { - read: vi.fn(async () => ({ content: "" })), + read: vi.fn(async () => ({ exists: false, content: null, path: null })), }, - backgroundTasks: { + tasks: { list: vi.fn( async (): Promise<{ tasks: Array> }> => ({ tasks: [], @@ -78,14 +76,17 @@ const runtimeMock = vi.hoisted(() => { disconnect: vi.fn(async () => undefined), getEvents: vi.fn(async (): Promise => []), setModel: vi.fn(async () => undefined), - send: vi.fn(async (): Promise => undefined), + send: vi.fn(async (_messageOptions: MessageOptions): Promise => undefined), abort: vi.fn(async () => undefined), }); const state = { startCalls: 0, stopCalls: 0, + forceStopCalls: 0, + stopErrors: [] as Error[], nativeWriteCalls: 0, + nativeWritePayloads: [] as unknown[], nativeWriteGate: null as Promise | null, createSessionConfigs: [] as SessionConfig[], resumeSessionCalls: [] as Array<{ readonly sessionId: string; readonly config: SessionConfig }>, @@ -101,7 +102,10 @@ const runtimeMock = vi.hoisted(() => { reset() { state.startCalls = 0; state.stopCalls = 0; + state.forceStopCalls = 0; + state.stopErrors = []; state.nativeWriteCalls = 0; + state.nativeWritePayloads = []; state.nativeWriteGate = null; state.createSessionConfigs.length = 0; state.resumeSessionCalls.length = 0; @@ -125,6 +129,10 @@ vi.mock("../copilotRuntime.ts", async () => { }), stop: vi.fn(async () => { runtimeMock.state.stopCalls += 1; + return runtimeMock.state.stopErrors; + }), + forceStop: vi.fn(async () => { + runtimeMock.state.forceStopCalls += 1; }), createSession: vi.fn(async (config: SessionConfig) => { runtimeMock.state.createSessionConfigs.push(config); @@ -144,13 +152,15 @@ vi.mock("../copilotRuntime.ts", async () => { beforeEach(() => { runtimeMock.reset(); + McpProviderSession.clearAllMcpProviderSessions(); }); const nativeEventLogger = { filePath: "memory://copilot-native-events.ndjson", - write: vi.fn(() => + write: vi.fn((event: unknown) => Effect.promise(async () => { runtimeMock.state.nativeWriteCalls += 1; + runtimeMock.state.nativeWritePayloads.push(event); const gate = runtimeMock.state.nativeWriteGate; if (gate) { await gate; @@ -160,6 +170,17 @@ const nativeEventLogger = { close: vi.fn(() => Effect.void), } satisfies EventNdjsonLogger; +function setMcpProviderSession(threadId: ThreadId, accessToken: string): void { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("copilot-adapter-test-environment"), + threadId, + providerSessionId: `mcp-provider-${threadId}`, + providerInstanceId: COPILOT_INSTANCE_ID, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: `Bearer ${accessToken}`, + }); +} + const CopilotAdapterTestLayer = Layer.effect( CopilotAdapter, makeCopilotAdapter(decodeCopilotSettings({}), { @@ -232,6 +253,137 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("correlates bootstrap auto-approvals before replaying early permission events", () => + Effect.gen(function* () { + const permissionRequest = { + kind: "write", + toolCallId: "tool-bootstrap-auto-approved-write", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as Extract; + const bootstrapTimestamp = yield* nowIso; + runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { + NodeAssert.ok(config.onEvent); + NodeAssert.ok(config.onPermissionRequest); + config.onEvent({ + id: "evt-copilot-bootstrap-auto-approved-requested", + timestamp: bootstrapTimestamp, + parentId: null, + type: "permission.requested", + data: { + requestId: "permission-bootstrap-auto-approved", + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + const result = await config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }); + config.onEvent({ + id: "evt-copilot-bootstrap-auto-approved-completed", + timestamp: bootstrapTimestamp, + parentId: null, + type: "permission.completed", + data: { + requestId: "permission-bootstrap-auto-approved", + result, + }, + } as SessionEvent); + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-bootstrap-permission-correlation"); + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "repeat the bootstrap write", + 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 timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-post-bootstrap-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-post-bootstrap-write" }, + } as SessionEvent); + const result = yield* Effect.promise(() => + Promise.resolve( + config.onPermissionRequest?.(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ), + ); + config.onEvent({ + id: "evt-copilot-post-bootstrap-write-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId: "permission-post-bootstrap-auto-approved", + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + config.onEvent({ + id: "evt-copilot-post-bootstrap-write-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId: "permission-post-bootstrap-auto-approved", + result, + }, + } 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.equal(diffUpdated.payload.unifiedDiff, permissionRequest.diff.trim()); + } + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "request.opened"), + false, + ); + NodeAssert.equal( + runtimeEvents.filter( + (event) => + event.type === "request.resolved" && + String(event.requestId) === "permission-post-bootstrap-auto-approved", + ).length, + 1, + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("only approves bootstrap edit permission requests in auto-accept-edits mode", () => Effect.gen(function* () { runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { @@ -307,6 +459,317 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("registers MCP auth and supplies the T3 bearer token on session creation", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-mcp-auth-create"); + const accessToken = "copilot-mcp-create-token"; + setMcpProviderSession(threadId, accessToken); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onMcpAuthRequest); + NodeAssert.equal(config.mcpOAuthTokenStorage, "in-memory"); + NodeAssert.deepStrictEqual(config.mcpServers, { + "t3-code": { + type: "http", + url: "http://127.0.0.1:43123/mcp", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + }); + + const result = yield* Effect.promise(() => + Promise.resolve( + config.onMcpAuthRequest?.( + { + requestId: "mcp-auth-create-request", + serverName: "t3-code", + serverUrl: "http://127.0.0.1:43123/mcp/", + reason: "initial", + }, + { sessionId: threadId }, + ), + ), + ); + NodeAssert.deepStrictEqual(result, { + kind: "token", + accessToken, + tokenType: "Bearer", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("registers MCP auth on resume and cancels when no T3 token is available", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-mcp-auth-resume"); + const sdkSessionId = "persisted-copilot-mcp-session"; + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { + schemaVersion: 1, + sessionId: sdkSessionId, + }, + }); + + const config = runtimeMock.state.resumeSessionCalls.at(-1)?.config; + NodeAssert.ok(config?.onMcpAuthRequest); + NodeAssert.equal(config.mcpOAuthTokenStorage, "in-memory"); + NodeAssert.equal(config.mcpServers, undefined); + const result = yield* Effect.promise(() => + Promise.resolve( + config.onMcpAuthRequest?.( + { + requestId: "mcp-auth-resume-request", + serverName: "external", + serverUrl: "https://mcp.example.test/rpc", + reason: "reauth", + }, + { sessionId: sdkSessionId }, + ), + ), + ); + NodeAssert.deepStrictEqual(result, { kind: "cancelled" }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps correlated MCP lifecycle events without leaking auth secrets", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-mcp-event-mapping"); + const accessToken = "copilot-mcp-event-token-secret"; + const clientSecret = "copilot-mcp-client-secret"; + const metadataSecret = "copilot-mcp-metadata-secret"; + const urlSecret = "copilot-mcp-url-secret"; + setMcpProviderSession(threadId, accessToken); + + 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.onMcpAuthRequest); + const timestamp = yield* nowIso; + const emit = (event: SessionEvent) => config.onEvent?.(event); + + const tokenResult = yield* Effect.promise(() => + Promise.resolve( + config.onMcpAuthRequest?.( + { + requestId: "mcp-oauth-token", + serverName: "t3-code", + serverUrl: "http://127.0.0.1:43123/mcp", + reason: "initial", + resourceMetadata: `{"private":"${metadataSecret}"}`, + staticClientConfig: { + clientId: "t3-code", + clientSecret, + }, + }, + { sessionId: threadId }, + ), + ), + ); + NodeAssert.deepStrictEqual(tokenResult, { + kind: "token", + accessToken, + tokenType: "Bearer", + }); + + emit({ + id: "evt-mcp-oauth-required-token", + timestamp, + parentId: null, + type: "mcp.oauth_required", + ephemeral: true, + data: { + requestId: "mcp-oauth-token", + serverName: "t3-code", + serverUrl: `http://127.0.0.1:43123/mcp?credential=${urlSecret}`, + reason: "initial", + resourceMetadata: `{"private":"${metadataSecret}"}`, + staticClientConfig: { + clientId: "t3-code", + clientSecret, + }, + wwwAuthenticateParams: { + scope: "preview:read", + }, + }, + } as SessionEvent); + emit({ + id: "evt-mcp-oauth-completed-token", + timestamp, + parentId: null, + type: "mcp.oauth_completed", + ephemeral: true, + data: { + requestId: "mcp-oauth-token", + outcome: "token", + }, + } as SessionEvent); + emit({ + id: "evt-mcp-oauth-required-cancelled", + timestamp, + parentId: null, + type: "mcp.oauth_required", + ephemeral: true, + data: { + requestId: "mcp-oauth-cancelled", + serverName: "external-oauth", + serverUrl: `https://mcp.example.test/rpc?credential=${urlSecret}`, + reason: "refresh", + resourceMetadata: `{"private":"${metadataSecret}"}`, + staticClientConfig: { + clientId: "external", + clientSecret, + }, + wwwAuthenticateParams: { + error: "invalid_token", + }, + }, + } as SessionEvent); + emit({ + id: "evt-mcp-oauth-completed-cancelled", + timestamp, + parentId: null, + type: "mcp.oauth_completed", + ephemeral: true, + data: { + requestId: "mcp-oauth-cancelled", + outcome: "cancelled", + }, + } as SessionEvent); + emit({ + id: "evt-mcp-headers-required", + timestamp, + parentId: null, + type: "mcp.headers_refresh_required", + ephemeral: true, + data: { + requestId: "mcp-headers-refresh", + serverName: "dynamic-headers", + serverUrl: `https://mcp.example.test/rpc?credential=${urlSecret}`, + reason: "auth-failed", + }, + } as SessionEvent); + emit({ + id: "evt-mcp-headers-completed", + timestamp, + parentId: null, + type: "mcp.headers_refresh_completed", + ephemeral: true, + data: { + requestId: "mcp-headers-refresh", + outcome: "none", + }, + } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + runtimeEvents.filter( + (event) => event.type === "mcp.status.updated" || event.type === "mcp.oauth.completed", + ).length < 6; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const tokenCompletion = runtimeEvents.find( + (event) => + event.type === "mcp.oauth.completed" && String(event.requestId) === "mcp-oauth-token", + ); + NodeAssert.equal(tokenCompletion?.type, "mcp.oauth.completed"); + if (tokenCompletion?.type === "mcp.oauth.completed") { + NodeAssert.deepStrictEqual(tokenCompletion.payload, { + success: true, + name: "t3-code", + }); + NodeAssert.equal(tokenCompletion.providerRefs?.providerRequestId, "mcp-oauth-token"); + } + + const cancelledCompletion = runtimeEvents.find( + (event) => + event.type === "mcp.oauth.completed" && String(event.requestId) === "mcp-oauth-cancelled", + ); + NodeAssert.equal(cancelledCompletion?.type, "mcp.oauth.completed"); + if (cancelledCompletion?.type === "mcp.oauth.completed") { + NodeAssert.deepStrictEqual(cancelledCompletion.payload, { + success: false, + name: "external-oauth", + error: "invalid_token", + }); + NodeAssert.equal( + cancelledCompletion.providerRefs?.providerRequestId, + "mcp-oauth-cancelled", + ); + } + + const headerStatuses = runtimeEvents.filter( + (event) => + event.type === "mcp.status.updated" && String(event.requestId) === "mcp-headers-refresh", + ); + NodeAssert.deepStrictEqual( + headerStatuses.map((event) => + event.type === "mcp.status.updated" ? event.payload.status : undefined, + ), + [ + { + lifecycle: "headers-refresh", + state: "required", + serverName: "dynamic-headers", + reason: "auth-failed", + }, + { + lifecycle: "headers-refresh", + state: "completed", + outcome: "none", + serverName: "dynamic-headers", + }, + ], + ); + + const observableOutput = encodeUnknownJson({ + runtimeEvents, + nativeWrites: runtimeMock.state.nativeWritePayloads, + }); + for (const secret of [accessToken, clientSecret, metadataSecret, urlSecret]) { + NodeAssert.equal(observableOutput.includes(secret), false); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits canonical answer maps for completed Copilot user input", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -480,6 +943,148 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("denies plan execution and emits the proposed plan for fresh sessions", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-plan-exit-policy-create"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Create a plan", + attachments: [], + }); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onExitPlanModeRequest); + NodeAssert.ok(config.onAutoModeSwitchRequest); + NodeAssert.deepStrictEqual( + yield* Effect.promise(() => + Promise.resolve( + config.onExitPlanModeRequest?.( + { + summary: "Plan ready", + planContent: "1. Inspect\n2. Implement", + actions: ["exit_only", "interactive"], + recommendedAction: "interactive", + }, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ), + ), + ), + { approved: false }, + ); + NodeAssert.equal( + yield* Effect.promise(() => + Promise.resolve( + config.onAutoModeSwitchRequest?.( + { errorCode: "rate_limited" }, + { sessionId: runtimeMock.state.lastSession.sessionId }, + ), + ), + ), + "yes", + ); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + const timestamp = yield* nowIso; + config.onEvent?.({ + id: "evt-copilot-exit-plan-requested", + timestamp, + parentId: null, + type: "exit_plan_mode.requested", + ephemeral: true, + data: { + requestId: "exit-plan-request-1", + summary: "Plan ready", + planContent: "1. Inspect\n2. Implement", + actions: ["exit_only", "interactive"], + recommendedAction: "interactive", + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const proposed = runtimeEvents.find((event) => event.type === "turn.proposed.completed"); + NodeAssert.equal(proposed?.type, "turn.proposed.completed"); + if (proposed?.type === "turn.proposed.completed") { + NodeAssert.equal(String(proposed.turnId), String(turn.turnId)); + NodeAssert.equal(proposed.payload.planMarkdown, "1. Inspect\n2. Implement"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("applies plan and one-time auto-switch policies to resumed sessions", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-plan-exit-policy-resume"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { + schemaVersion: 1, + sessionId: "persisted-copilot-session", + }, + }); + + const config = runtimeMock.state.resumeSessionCalls.at(-1)?.config; + NodeAssert.ok(config?.onExitPlanModeRequest); + NodeAssert.ok(config.onAutoModeSwitchRequest); + NodeAssert.deepStrictEqual( + yield* Effect.promise(() => + Promise.resolve( + config.onExitPlanModeRequest?.( + { + summary: "Persisted plan", + actions: ["exit_only"], + recommendedAction: "exit_only", + }, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ), + ), + ), + { approved: false }, + ); + NodeAssert.equal( + yield* Effect.promise(() => + Promise.resolve( + config.onAutoModeSwitchRequest?.( + { + errorCode: "user_weekly_rate_limited", + retryAfterSeconds: 60, + }, + { + sessionId: runtimeMock.state.lastSession.sessionId, + }, + ), + ), + ), + "yes", + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a fresh session when the persisted Copilot resume cursor is missing", () => Effect.gen(function* () { runtimeMock.state.resumeSessionImpl = async (sessionId: string) => { @@ -517,6 +1122,155 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("force stops the Copilot client when graceful session cleanup is incomplete", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-incomplete-client-cleanup"); + runtimeMock.state.stopErrors = [new Error("Copilot process remained alive")]; + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.stopSession(threadId); + + NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(runtimeMock.state.forceStopCalls, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("rolls back resumed history without local turn snapshots", () => + Effect.gen(function* () { + const timestamp = yield* nowIso; + runtimeMock.state.lastSession.getEvents.mockResolvedValue([ + { + id: "persisted-root-user-1", + timestamp, + parentId: null, + type: "user.message", + data: { content: "first persisted prompt" }, + } as SessionEvent, + { + id: "persisted-assistant-1", + timestamp, + parentId: "persisted-root-user-1", + type: "assistant.message", + data: { content: "first response" }, + } as SessionEvent, + { + id: "persisted-subagent-user-1", + timestamp, + parentId: "persisted-assistant-1", + type: "user.message", + agentId: "subagent-1", + data: { content: "subagent prompt" }, + } as SessionEvent, + { + id: "persisted-root-user-2", + timestamp, + parentId: "persisted-subagent-user-1", + type: "user.message", + data: { content: "second persisted prompt" }, + } as SessionEvent, + { + id: "persisted-subagent-user-2", + timestamp, + parentId: "persisted-root-user-2", + type: "user.message", + agentId: "subagent-2", + data: { content: "another subagent prompt" }, + } as SessionEvent, + { + id: "persisted-root-user-3", + timestamp, + parentId: "persisted-subagent-user-2", + type: "user.message", + data: { content: "third persisted prompt" }, + } as SessionEvent, + ]); + + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-resumed-history-rollback"); + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { + schemaVersion: 1, + sessionId: "persisted-copilot-session", + }, + }); + + NodeAssert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, []); + const snapshot = yield* adapter.rollbackThread(threadId, 2); + + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ + [{ eventId: "persisted-root-user-2" }], + ]); + NodeAssert.deepStrictEqual(snapshot.turns, []); + + runtimeMock.state.lastSession.getEvents.mockResolvedValueOnce([ + { + id: "persisted-root-user-1", + timestamp, + parentId: null, + type: "user.message", + data: { content: "first persisted prompt" }, + } as SessionEvent, + ]); + const missingBoundaryError = yield* adapter.rollbackThread(threadId, 2).pipe(Effect.flip); + NodeAssert.match(missingBoundaryError.message, /contains only 1 root user message/); + NodeAssert.equal(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stops a session that is still starting when stopAll begins", () => + Effect.gen(function* () { + let releaseCreateSession!: () => void; + const createSessionGate = new Promise((resolve) => { + releaseCreateSession = resolve; + }); + let markCreateSessionStarted!: () => void; + const createSessionStarted = new Promise((resolve) => { + markCreateSessionStarted = resolve; + }); + runtimeMock.state.createSessionImpl = async () => { + markCreateSessionStarted(); + await createSessionGate; + return runtimeMock.state.lastSession as unknown as CopilotSession; + }; + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-all-during-start"); + + const startFiber = yield* adapter + .startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => createSessionStarted); + + const stopAllFiber = yield* adapter.stopAll().pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.stopCalls, 0); + + releaseCreateSession(); + yield* Fiber.join(startFiber); + yield* Fiber.join(stopAllFiber); + + NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("rolls back persisted Copilot history from the first removed user message", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -710,7 +1464,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { } const missingBoundaryError = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); - NodeAssert.match(missingBoundaryError.message, /without the first removed user message ID/); + NodeAssert.match(missingBoundaryError.message, /contains only 0 root user messages/); NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ [{ eventId: "user-message-second" }], [{ eventId: "user-message-recovered" }], @@ -918,7 +1672,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMock.state.lastSession.rpc.history.truncate.mockImplementationOnce(async () => { markTruncateStarted(); await truncateGate; - return { removedEventCount: 1 }; + return { eventsRemoved: 1 }; }); let markRollbackFinished!: () => void; @@ -1166,22 +1920,110 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - runtimeMock.state.lastSession.rpc.mode.set.mockRejectedValueOnce( - new Error("Copilot mode update rejected"), - ); - runtimeMock.state.lastSession.send.mockClear(); - const result = yield* adapter + runtimeMock.state.lastSession.rpc.mode.set.mockRejectedValueOnce( + new Error("Copilot mode update rejected"), + ); + runtimeMock.state.lastSession.send.mockClear(); + const result = yield* adapter + .sendTurn({ + threadId, + input: "do not send this prompt", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.lastSession.send.mock.calls.length, 0); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("serializes each turn's model and mode through its SDK send", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-turn-configuration-serialization"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + runtimeMock.state.lastSession.setModel.mockClear(); + runtimeMock.state.lastSession.rpc.mode.set.mockClear(); + runtimeMock.state.lastSession.send.mockClear(); + let markFirstModelStarted!: () => void; + const firstModelStarted = new Promise((resolve) => { + markFirstModelStarted = resolve; + }); + let releaseFirstModel!: () => void; + const firstModelGate = new Promise((resolve) => { + releaseFirstModel = resolve; + }); + runtimeMock.state.lastSession.setModel.mockImplementationOnce(async () => { + markFirstModelStarted(); + await firstModelGate; + }); + + const firstTurnFiber = yield* adapter .sendTurn({ threadId, - input: "do not send this prompt", + input: "plan the first change", attachments: [], interactionMode: "plan", + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [], + }, }) - .pipe(Effect.result); + .pipe(Effect.forkChild); + yield* Effect.promise(() => firstModelStarted); - NodeAssert.equal(result._tag, "Failure"); + const secondTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "implement the second change", + attachments: [], + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "gpt-4.1", + options: [], + }, + }) + .pipe(Effect.forkChild); + yield* waitForSdkEventQueue(); + + NodeAssert.equal(runtimeMock.state.lastSession.setModel.mock.calls.length, 1); + NodeAssert.equal(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.length, 0); NodeAssert.equal(runtimeMock.state.lastSession.send.mock.calls.length, 0); + releaseFirstModel(); + yield* Fiber.join(firstTurnFiber); + yield* Fiber.join(secondTurnFiber); + + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls, [ + ["claude-sonnet-4.6", {}], + ["gpt-4.1", {}], + ]); + NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls, [ + [{ mode: "plan" }], + [{ mode: "interactive" }], + ]); + NodeAssert.deepStrictEqual( + runtimeMock.state.lastSession.send.mock.calls.map(([messageOptions]) => ({ + prompt: messageOptions.prompt, + agentMode: messageOptions.agentMode, + })), + [ + { prompt: "plan the first change", agentMode: "plan" }, + { prompt: "implement the second change", agentMode: "interactive" }, + ], + ); + yield* adapter.stopSession(threadId); }), ); @@ -1619,7 +2461,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits user interrupt aborts from the SDK abort event", () => + it.effect("only aborts the matching active Copilot turn", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-interrupt-sdk-abort-source"); @@ -1644,6 +2486,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); + yield* adapter.interruptTurn(threadId, TurnId.make("stale-turn-id")); + NodeAssert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 0); + yield* adapter.interruptTurn(threadId, turn.turnId); NodeAssert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); yield* waitForSdkEventQueue(); @@ -1652,6 +2497,26 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-abort-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-aborted-partial", + }, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-abort-message-delta", + timestamp, + parentId: null, + ephemeral: true, + type: "assistant.message_delta", + data: { + messageId: "message-aborted-partial", + deltaContent: "Partial response before abort.", + }, + } as SessionEvent); config.onEvent({ id: "evt-copilot-abort", timestamp, @@ -1678,6 +2543,30 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { if (completed?.type === "turn.completed") { NodeAssert.equal(completed.payload.state, "cancelled"); } + const assistantCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && + String(event.itemId) === "copilot-message-message-aborted-partial", + ); + NodeAssert.equal(assistantCompleted?.type, "item.completed"); + if (assistantCompleted?.type === "item.completed") { + NodeAssert.equal(assistantCompleted.payload.status, "failed"); + } + const snapshot = (yield* adapter.readThread(threadId)).turns.find( + (entry) => entry.id === turn.turnId, + ); + NodeAssert.ok(snapshot); + NodeAssert.deepStrictEqual(snapshot.items, [ + { + type: "assistant_message", + messageId: "message-aborted-partial", + content: "Partial response before abort.", + }, + ]); + + yield* adapter.interruptTurn(threadId, turn.turnId); + yield* adapter.interruptTurn(threadId); + NodeAssert.equal(runtimeMock.state.lastSession.abort.mock.calls.length, 1); yield* adapter.stopSession(threadId); }), @@ -2880,29 +3769,251 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }, } 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); + let diffEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits turn diff when a Copilot write permission is approved", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-write-permission-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "update the README", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const timestamp = yield* nowIso; + const requestId = "permission-write-readme"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-write-readme", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as Extract; + + emit({ + id: "evt-copilot-write-permission-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + emit({ + id: "evt-copilot-write-permission-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + NodeAssert.equal(opened?.type, "request.opened"); + if (opened?.type === "request.opened") { + NodeAssert.equal(opened.payload.requestType, "file_change_approval"); + NodeAssert.equal(String(opened.turnId), String(turn.turnId)); + } + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const approvalResult = yield* Effect.promise(() => resultPromise); + NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + + emit({ + id: "evt-copilot-write-permission-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result: approvalResult, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-write-permission-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-write-permission", + }, + } as SessionEvent); + + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(diffUpdated.payload, { + unifiedDiff: permissionRequest.diff.trim(), + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("tracks auto-approved Copilot writes through permission completion", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-auto-approved-write-permission-turn-diff"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "update the README without prompting", + 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 timestamp = yield* nowIso; + const requestId = "permission-auto-approved-write-readme"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-auto-approved-write-readme", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as Extract; + + config.onEvent({ + id: "evt-copilot-auto-approved-write-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-auto-approved-write" }, + } as SessionEvent); + const approvalResult = yield* Effect.promise(() => + Promise.resolve( + config.onPermissionRequest?.(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ), + ); + NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + config.onEvent({ + id: "evt-copilot-auto-approved-write-requested", + timestamp, + parentId: null, + type: "permission.requested", + data: { + requestId, + permissionRequest, + promptRequest: undefined, + }, + } as unknown as SessionEvent); + config.onEvent({ + id: "evt-copilot-auto-approved-write-completed", + timestamp, + parentId: null, + type: "permission.completed", + data: { + requestId, + result: approvalResult, + }, + } 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(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - NodeAssert.equal(diffEvent.turnId, turn.turnId); - NodeAssert.deepStrictEqual(diffEvent.payload, { - unifiedDiff: diff.trim(), - }); - } + NodeAssert.equal( + runtimeEvents.some( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ), + false, + ); + const resolved = runtimeEvents.filter( + (event) => event.type === "request.resolved" && String(event.requestId) === requestId, + ); + NodeAssert.equal(resolved.length, 1); + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.equal(diffUpdated.payload.unifiedDiff, permissionRequest.diff.trim()); + } - yield* adapter.stopSession(threadId); - }), + yield* adapter.stopSession(threadId); + }), ); - it.effect("emits turn diff when a Copilot write permission is approved", () => + it.effect("tracks Copilot writes resolved by an SDK permission hook", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-write-permission-turn-diff"); + const threadId = asThreadId("copilot-sdk-hook-approved-write-turn-diff"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2910,10 +4021,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { cwd: process.cwd(), runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ threadId, - input: "update the README", + input: "apply the SDK-hook-approved write", attachments: [], }); @@ -2926,36 +4036,26 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); - const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; - const requestId = "permission-write-readme"; + const requestId = "permission-sdk-hook-approved-write"; const permissionRequest = { kind: "write", - toolCallId: "tool-write-readme", + toolCallId: "tool-sdk-hook-approved-write", fileName: "README.md", diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", intention: "Update README", canOfferSessionApproval: true, } as Extract; - emit({ - id: "evt-copilot-write-permission-turn-start", + config.onEvent({ + id: "evt-copilot-sdk-hook-approved-turn-start", timestamp, parentId: null, type: "assistant.turn_start", - data: { - turnId: "sdk-turn-write-permission", - }, + data: { turnId: "sdk-turn-hook-approved-write" }, } as SessionEvent); - - const resultPromise = Promise.resolve( - config.onPermissionRequest(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }), - ); - emit({ - id: "evt-copilot-write-permission-requested", + config.onEvent({ + id: "evt-copilot-sdk-hook-approved-write-requested", timestamp, parentId: null, type: "permission.requested", @@ -2963,56 +4063,37 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { requestId, permissionRequest, promptRequest: undefined, + resolvedByHook: true, }, } as unknown as SessionEvent); - - let opened: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - opened = runtimeEvents.find( - (event) => event.type === "request.opened" && String(event.requestId) === requestId, - ); - } - NodeAssert.equal(opened?.type, "request.opened"); - if (opened?.type === "request.opened") { - NodeAssert.equal(opened.payload.requestType, "file_change_approval"); - NodeAssert.equal(String(opened.turnId), String(turn.turnId)); - } - - yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); - const approvalResult = yield* Effect.promise(() => resultPromise); - NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); - - emit({ - id: "evt-copilot-write-permission-completed", + config.onEvent({ + id: "evt-copilot-sdk-hook-approved-write-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", + result: { kind: "approved" }, }, } as SessionEvent); - yield* waitForSdkEventQueue(); + 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); - const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + NodeAssert.equal( + runtimeEvents.some( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ), + false, + ); 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(), - }); + NodeAssert.equal(diffUpdated.payload.unifiedDiff, permissionRequest.diff.trim()); } yield* adapter.stopSession(threadId); @@ -3267,7 +4348,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -3390,7 +4471,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; - runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -3425,7 +4506,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); } - runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ tasks: [ { type: "agent", @@ -3588,7 +4669,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.tasks.list.mockResolvedValueOnce({ + runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ tasks: [], }); @@ -3634,85 +4715,6 @@ 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 { - tasks?: unknown; - rpc: { tasks?: unknown; backgroundTasks?: unknown }; - }; - delete session.tasks; - delete session.rpc.tasks; - delete session.rpc.backgroundTasks; - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-background-tasks-without-list", - timestamp, - parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-background-tasks-drain-marker", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-after-background-tasks", - }, - } as SessionEvent); - - let markerEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - markerEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.reason === "Copilot turn started", - ); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(markerEvent?.type, "session.state.changed"); - NodeAssert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, - ); - NodeAssert.equal( - runtimeEvents.find((event) => event.type === "runtime.error"), - undefined, - ); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("emits command metadata without unused reasoning or tool output deltas", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -3968,7 +4970,88 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("completes the active turn as failed when Copilot reports a session error", () => + it.effect("keeps eligible Copilot rate-limit errors non-terminal for automatic fallback", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-eligible-rate-limit"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "continue with an automatic fallback", + attachments: [], + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-eligible-rate-limit", + timestamp, + parentId: null, + type: "session.error", + data: { + errorType: "rate_limit", + errorCode: "user_weekly_rate_limited", + message: "Weekly premium model limit reached", + eligibleForAutoSwitch: true, + providerCallId: "provider-call-1", + serviceRequestId: "service-request-1", + statusCode: 429, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + const rateLimitEvent = runtimeEvents.find( + (event) => event.type === "account.rate-limits.updated", + ); + NodeAssert.equal(rateLimitEvent?.type, "account.rate-limits.updated"); + if (rateLimitEvent?.type === "account.rate-limits.updated") { + NodeAssert.deepStrictEqual(rateLimitEvent.payload.rateLimits, { + errorType: "rate_limit", + errorCode: "user_weekly_rate_limited", + message: "Weekly premium model limit reached", + eligibleForAutoSwitch: true, + providerCallId: "provider-call-1", + serviceRequestId: "service-request-1", + statusCode: 429, + }); + } + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "runtime.error"), + false, + ); + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ), + false, + ); + const session = (yield* adapter.listSessions()).find( + (entry) => String(entry.threadId) === String(threadId), + ); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); + + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits and terminally fails non-eligible Copilot quota errors", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-session-error-completes-active-turn"); @@ -4005,13 +5088,39 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-session-error", }, } as SessionEvent); + config.onEvent({ + id: "evt-copilot-session-error-message-delta", + timestamp, + parentId: null, + ephemeral: true, + type: "assistant.message_delta", + data: { + messageId: "message-session-error-partial", + deltaContent: "Partial response before provider error.", + }, + } as SessionEvent); + config.onEvent({ + id: "evt-copilot-session-error-second-message-delta", + timestamp, + parentId: null, + ephemeral: true, + type: "assistant.message_delta", + data: { + messageId: "message-session-error-second-partial", + deltaContent: "Second partial response before provider error.", + }, + } as SessionEvent); config.onEvent({ id: "evt-copilot-session-error", timestamp, parentId: null, type: "session.error", data: { - message: "Copilot runtime crashed", + errorType: "quota", + errorCode: "quota_exceeded", + message: "Copilot quota exceeded", + eligibleForAutoSwitch: false, + statusCode: 429, }, } as SessionEvent); @@ -4026,12 +5135,54 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + const rateLimitEvent = runtimeEvents.find( + (event) => event.type === "account.rate-limits.updated", + ); + NodeAssert.equal(rateLimitEvent?.type, "account.rate-limits.updated"); + if (rateLimitEvent?.type === "account.rate-limits.updated") { + NodeAssert.deepStrictEqual(rateLimitEvent.payload.rateLimits, { + errorType: "quota", + errorCode: "quota_exceeded", + message: "Copilot quota exceeded", + eligibleForAutoSwitch: false, + statusCode: 429, + }); + } NodeAssert.equal(runtimeError?.type, "runtime.error"); NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { NodeAssert.equal(completed.payload.state, "failed"); - NodeAssert.equal(completed.payload.errorMessage, "Copilot runtime crashed"); + NodeAssert.equal(completed.payload.errorMessage, "Copilot quota exceeded"); + } + const assistantCompleted = runtimeEvents.filter( + (event) => + event.type === "item.completed" && + (String(event.itemId) === "copilot-message-message-session-error-partial" || + String(event.itemId) === "copilot-message-message-session-error-second-partial"), + ); + NodeAssert.equal(assistantCompleted.length, 2); + for (const completedItem of assistantCompleted) { + NodeAssert.equal(completedItem.type, "item.completed"); + if (completedItem.type === "item.completed") { + NodeAssert.equal(completedItem.payload.status, "failed"); + } } + const snapshot = (yield* adapter.readThread(threadId)).turns.find( + (entry) => entry.id === turn.turnId, + ); + NodeAssert.ok(snapshot); + NodeAssert.deepStrictEqual(snapshot.items, [ + { + type: "assistant_message", + messageId: "message-session-error-partial", + content: "Partial response before provider error.", + }, + { + type: "assistant_message", + messageId: "message-session-error-second-partial", + content: "Second partial response before provider error.", + }, + ]); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index c30018fdb368..0947857f441d 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -42,11 +42,11 @@ import * as Predicate from "effect/Predicate"; import * as PubSub from "effect/PubSub"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -55,7 +55,9 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; -import { createCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; +import { resolveCopilotMcpBearerAuth } from "../copilotMcpBearerAuth.ts"; +import { createCopilotClient, stopCopilotClient, trimOrUndefined } from "../copilotRuntime.ts"; +import { makeThreadLifecycleLock } from "../threadLifecycleLock.ts"; import { classifyCopilotToolItemType, isReadOnlyCopilotToolName, @@ -86,39 +88,15 @@ type CopilotUserInputResponse = Awaited< type SessionPermissionRequestedEvent = Extract; type SessionStartedEvent = Extract; type SessionResumedEvent = Extract; +type SessionUserMessageEvent = Extract; type SessionUserInputRequestedEvent = Extract; type SessionUserInputCompletedEvent = Extract; type SessionPermissionRequest = SessionPermissionRequestedEvent["data"]["permissionRequest"]; type SessionApprovalDecision = Extract; type SessionApproval = NonNullable; -type CopilotTaskStatus = "running" | "idle" | "completed" | "failed" | "cancelled"; -type CopilotTaskInfo = { - readonly id?: string; - readonly type?: string; - readonly description: string; - readonly status: CopilotTaskStatus; - readonly agentType?: string; - readonly command?: string; - readonly prompt?: string; - readonly latestResponse?: string; - readonly result?: string; - readonly error?: string; -}; -type CopilotTaskList = { - readonly tasks: ReadonlyArray; -}; -type CopilotSessionTasks = { - readonly list?: () => Promise; -}; -type CopilotTasksAccessor = { - readonly tasks?: CopilotSessionTasks; -}; -type CopilotBackgroundTasksRpc = { - readonly backgroundTasks?: { - readonly list?: () => Promise; - }; - readonly tasks?: CopilotSessionTasks; -}; +type CopilotTaskList = Awaited>; +type CopilotTaskInfo = CopilotTaskList["tasks"][number]; +type CopilotTaskStatus = CopilotTaskInfo["status"]; type PlanStep = { step: string; @@ -154,6 +132,7 @@ interface CopilotTurnStartPayload { interface PendingPermissionHandler { readonly signature: string; readonly deferred: Deferred.Deferred; + readonly resolvedByAdapter: boolean; } interface PendingUserInputHandler { @@ -169,6 +148,7 @@ interface PendingPermissionBinding { readonly permissionRequest: SessionPermissionRequest; readonly promptRequest: SessionPermissionRequestedEvent["data"]["promptRequest"] | undefined; readonly deferred: Deferred.Deferred; + readonly resolvedByAdapter: boolean; } interface PendingUserInputBinding { @@ -179,6 +159,11 @@ interface PendingUserInputBinding { readonly deferred: Deferred.Deferred; } +interface PendingMcpOauthRequest { + readonly serverName: string; + readonly error?: string | undefined; +} + interface ToolMeta { readonly toolName: string; readonly itemType: @@ -227,15 +212,18 @@ interface CopilotSessionContext { Array >; readonly pendingUserInputBindings: Map; + readonly pendingMcpOauthRequests: Map; + readonly pendingMcpHeadersRefreshRequests: Map; readonly toolMetaById: Map; readonly turnIdByProviderItemId: Map; readonly emittedTextByItemId: Map; - readonly assistantItemIdByTurnId: Map; + readonly assistantItemIdsByTurnId: Map>; readonly pendingTaskCompletionTextByTurnId: Map; readonly emittedTurnDiffByTurnId: Map; readonly copilotTasks: Map; readonly turnIdsWithAssistantText: Set; readonly startedItemIds: Set; + readonly completedAssistantItemIds: Set; readonly turnEndEventsByTurnId: Map; readonly turnEndFallbackTimers: Map>; activeTurnId: TurnId | undefined; @@ -254,11 +242,51 @@ const EMPTY_USER_INPUT_RESPONSE = { answer: "", wasFreeform: true, } satisfies CopilotUserInputResponse; +const DENY_EXIT_PLAN_MODE: NonNullable = () => ({ + approved: false, +}); +const APPROVE_AUTO_MODE_SWITCH_ONCE: NonNullable = () => + "yes"; +const CANCELLED_MCP_AUTH_RESULT = { kind: "cancelled" } as const; function nowIso(): string { return DateTime.formatIso(DateTime.nowUnsafe()); } +function eventForNativeLog(event: SessionEvent): unknown { + if (event.type === "mcp.oauth_required") { + const scope = trimOrUndefined(event.data.wwwAuthenticateParams?.scope); + const error = trimOrUndefined(event.data.wwwAuthenticateParams?.error); + return { + ...event, + data: { + requestId: event.data.requestId, + serverName: event.data.serverName, + reason: event.data.reason, + ...(scope || error + ? { + wwwAuthenticateParams: { + ...(scope ? { scope } : {}), + ...(error ? { error } : {}), + }, + } + : {}), + }, + }; + } + if (event.type === "mcp.headers_refresh_required") { + return { + ...event, + data: { + requestId: event.data.requestId, + serverName: event.data.serverName, + reason: event.data.reason, + }, + }; + } + return event; +} + function parseCopilotResumeCursor(raw: unknown): { sessionId: string } | undefined { if ( !Predicate.hasProperty(raw, "schemaVersion") || @@ -711,17 +739,6 @@ function completedToolDiffText( return parseTurnDiffFilesFromUnifiedDiff(diffCandidate).length > 0 ? diffCandidate : undefined; } -function copilotBackgroundTasksList( - session: CopilotSession, -): (() => Promise) | undefined { - const sessionTasks = (session as CopilotSession & CopilotTasksAccessor).tasks?.list; - if (sessionTasks) { - return sessionTasks; - } - const rpc = session.rpc as typeof session.rpc & CopilotBackgroundTasksRpc; - return rpc.tasks?.list ?? rpc.backgroundTasks?.list; -} - function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status"] { switch (status) { case "completed": @@ -756,21 +773,15 @@ function readString(value: unknown): string | undefined { } function copilotTaskId(task: CopilotTaskInfo): string | undefined { - return readString(task.id); + return task.id; } function copilotTaskType(task: CopilotTaskInfo): string | undefined { - return readString(task.type) ?? readString(task.agentType); + return task.type === "agent" ? task.agentType : task.type; } function copilotTaskDescription(task: CopilotTaskInfo): string { - return ( - readString(task.description) ?? - readString(task.command) ?? - readString(task.prompt) ?? - readString(task.latestResponse) ?? - "Task" - ); + return task.description; } function copilotTaskProgressSummary(status: CopilotTaskStatus): string { @@ -778,12 +789,10 @@ function copilotTaskProgressSummary(status: CopilotTaskStatus): string { } function copilotTaskCompletionSummary(task: CopilotTaskInfo): string | undefined { - return ( - readString(task.error) ?? - readString(task.result) ?? - readString(task.latestResponse) ?? - copilotTaskDescription(task) - ); + if (task.type === "shell") { + return task.description; + } + return task.error ?? task.result ?? task.latestResponse ?? task.description; } function copilotTaskStatusSuffix(status: CopilotTaskStatus): string { @@ -1206,7 +1215,7 @@ function resolveTurnIdForSdkUserMessage( function bindSdkUserMessageToTurn( context: CopilotSessionContext, - event: Extract, + event: SessionUserMessageEvent, ): TurnId | undefined { const turnId = resolveTurnIdForSdkUserMessage(context, event); if (!turnId) { @@ -1217,6 +1226,10 @@ function bindSdkUserMessageToTurn( return turnId; } +function isRootSdkUserMessage(event: SessionEvent): event is SessionUserMessageEvent { + return event.type === "user.message" && event.agentId === undefined; +} + function resolveTurnIdForEvent( context: CopilotSessionContext, input?: { @@ -1270,7 +1283,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const sessions = new Map(); - const lifecycleLocksRef = yield* SynchronizedRef.make(new Map()); + const lifecycleLock = yield* makeThreadLifecycleLock(); const path = yield* Path.Path; const runtimeContext = yield* Effect.context(); const runWithContext = Effect.runPromiseWith(runtimeContext); @@ -1282,22 +1295,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const emit = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - const getLifecycleSemaphore = (threadId: ThreadId) => - SynchronizedRef.modifyEffect(lifecycleLocksRef, (current) => { - const existing = current.get(threadId); - if (existing) { - return Effect.succeed([existing, current] as const); - } - return Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ); - }); - const withLifecycleLock = (threadId: ThreadId, effect: Effect.Effect) => - Effect.flatMap(getLifecycleSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const withLifecycleLock = lifecycleLock.withLock; const emitAsync = (event: ProviderRuntimeEvent) => runWithContext(emit(event)); const emitTurnStarted = ( context: CopilotSessionContext, @@ -1327,7 +1325,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const writeNativeAsync = (threadId: ThreadId, event: SessionEvent) => nativeEventLogger ? runWithContext( - nativeEventLogger.write({ source: "copilot.sdk.event", payload: event }, threadId), + nativeEventLogger.write( + { source: "copilot.sdk.event", payload: eventForNativeLog(event) }, + threadId, + ), ) : Promise.resolve(); @@ -1344,16 +1345,17 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), }), stopClient: (threadId: ThreadId, client: CopilotClient) => - Effect.tryPromise({ - try: () => client.stop(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: detailFromCause(cause, "Failed to stop Copilot client."), - cause, - }), - }), + stopCopilotClient(client).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: detailFromCause(cause, "Failed to stop Copilot client."), + cause, + }), + ), + ), createSession: ( threadId: ThreadId, client: CopilotClient, @@ -1441,9 +1443,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }), readBackgroundTasks: ( context: CopilotSessionContext, - ): Effect.Effect => + ): Effect.Effect => Effect.tryPromise({ - try: () => copilotBackgroundTasksList(context.sdkSession)?.() ?? Promise.resolve(undefined), + try: () => context.sdkSession.rpc.tasks.list(), catch: (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, @@ -1560,6 +1562,49 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } } + const completeAssistantTextItem = async (input: { + readonly context: CopilotSessionContext; + readonly turnId: TurnId; + readonly itemId: string; + readonly messageId?: string | undefined; + readonly status: "completed" | "failed"; + readonly detail?: string | undefined; + readonly raw?: SessionEvent | undefined; + }) => { + if (input.context.completedAssistantItemIds.has(input.itemId)) { + return; + } + const content = input.context.emittedTextByItemId.get(input.itemId); + if (content === undefined) { + return; + } + + await emitAsync({ + ...createBaseEvent({ + threadId: input.context.threadId, + turnId: input.turnId, + itemId: input.itemId, + raw: input.raw, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: input.status, + ...(input.detail ? { detail: input.detail } : {}), + }, + }); + appendTurnItem(input.context, input.turnId, { + type: "assistant_message", + messageId: + input.messageId ?? + (input.itemId.startsWith("copilot-message-") + ? input.itemId.slice("copilot-message-".length) + : input.itemId), + content, + }); + input.context.completedAssistantItemIds.add(input.itemId); + }; + const emitTurnCompleted = async ( context: CopilotSessionContext, turnId: TurnId, @@ -1571,6 +1616,16 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }, ) => { cancelTurnEndFallback(context, turnId); + const assistantItemIds = context.assistantItemIdsByTurnId.get(turnId); + for (const assistantItemId of assistantItemIds ?? []) { + await completeAssistantTextItem({ + context, + turnId, + itemId: assistantItemId, + status: status === "completed" ? "completed" : "failed", + raw: input?.raw, + }); + } // 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)) { @@ -1770,11 +1825,14 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const previousText = input.context.emittedTextByItemId.get(input.itemId); const delta = deltaFromBufferedText(previousText, input.nextText); input.context.emittedTextByItemId.set(input.itemId, input.nextText); + const assistantItemIds = + input.context.assistantItemIdsByTurnId.get(input.turnId) ?? new Set(); + assistantItemIds.add(input.itemId); + input.context.assistantItemIdsByTurnId.set(input.turnId, assistantItemIds); if (delta.length === 0) { return; } input.context.turnIdsWithAssistantText.add(input.turnId); - input.context.assistantItemIdByTurnId.set(input.turnId, input.itemId); await emitAsync({ ...createBaseEvent({ threadId: input.context.threadId, @@ -1809,27 +1867,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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", + await completeAssistantTextItem({ + context, + turnId, + itemId, messageId: itemId, - content, + status: "completed", + detail: content, + raw, }); - context.assistantItemIdByTurnId.set(turnId, itemId); - context.turnIdsWithAssistantText.add(turnId); }; const emitPermissionRequestOpened = ( @@ -1943,8 +1989,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( permissionRequest: eventData.permissionRequest, promptRequest: eventData.promptRequest, deferred: handler.deferred, + resolvedByAdapter: handler.resolvedByAdapter, }); - if (eventData.resolvedByHook !== true) { + if (eventData.resolvedByHook !== true && !handler.resolvedByAdapter) { yield* emitPermissionRequestOpened( context, context.pendingPermissionBindings.get(requestId)!, @@ -2012,9 +2059,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } const taskList = yield* copilotSdk.readBackgroundTasks(context); - if (!taskList) { - return; - } for (const task of taskList.tasks) { const taskId = copilotTaskId(task); if (!taskId) { @@ -2100,23 +2144,32 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const onPermissionRequest = ( context: CopilotSessionContext, request: PermissionRequest, + resolvedResult?: PermissionRequestResult | undefined, ): Effect.Effect => Effect.gen(function* () { if (context.stopped) { return DENIED_PERMISSION_RESULT; } - if (permissionAutoApprovedByRuntimeMode(context.session.runtimeMode, request)) { - return APPROVED_PERMISSION_RESULT; - } const signature = permissionSignature(request); const deferred = yield* Deferred.make(); + const adapterResult = + resolvedResult ?? + (permissionAutoApprovedByRuntimeMode(context.session.runtimeMode, request) + ? APPROVED_PERMISSION_RESULT + : undefined); const queue = context.pendingPermissionHandlersBySignature.get(signature) ?? []; queue.push({ signature, deferred, + resolvedByAdapter: adapterResult !== undefined, }); context.pendingPermissionHandlersBySignature.set(signature, queue); + if (adapterResult !== undefined) { + yield* Deferred.succeed(deferred, adapterResult); + yield* bindPermissionRequests(context, signature); + return adapterResult; + } yield* bindPermissionRequests(context, signature); return yield* Deferred.await(deferred); }); @@ -2144,6 +2197,23 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return yield* Deferred.await(deferred); }); + const makeMcpAuthHandler = ( + threadId: ThreadId, + expectedSdkSessionId: string, + ): NonNullable => { + return (request, handlerContext) => { + if (handlerContext.sessionId !== expectedSdkSessionId) { + return CANCELLED_MCP_AUTH_RESULT; + } + const mcpSession = McpProviderSession.readMcpProviderSession(threadId); + if (mcpSession?.providerInstanceId !== boundInstanceId) { + return CANCELLED_MCP_AUTH_RESULT; + } + const auth = resolveCopilotMcpBearerAuth(mcpSession, request.serverUrl); + return auth ? { kind: "token", ...auth } : CANCELLED_MCP_AUTH_RESULT; + }; + }; + const syncSessionMode = ( context: CopilotSessionContext, mode: CopilotMode, @@ -2301,6 +2371,24 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "session.error": { const message = trimOrUndefined(event.data.message) ?? "Copilot session failed."; + const isAccountLimitError = + event.data.errorType === "rate_limit" || event.data.errorType === "quota"; + if (isAccountLimitError) { + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + turnId: context.activeTurnId, + raw: event, + }), + type: "account.rate-limits.updated", + payload: { + rateLimits: event.data, + }, + }); + if (event.data.eligibleForAutoSwitch === true) { + return; + } + } const activeTurnId = context.activeTurnId; updateProviderSession(context, { status: "error", @@ -2529,7 +2617,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); - context.assistantItemIdByTurnId.set(turnId, itemId); await emitAssistantTextDelta({ context, turnId, @@ -2552,7 +2639,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } const itemId = `copilot-message-${event.data.messageId}`; context.turnIdByProviderItemId.set(event.data.messageId, turnId); - context.assistantItemIdByTurnId.set(turnId, itemId); await emitAssistantTextDelta({ context, turnId, @@ -2560,23 +2646,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( 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", - }, - }); - appendTurnItem(context, turnId, { - type: "assistant_message", + await completeAssistantTextItem({ + context, + turnId, + itemId, messageId: event.data.messageId, - content: event.data.content, + status: "completed", + raw: event, }); return; } @@ -2813,11 +2889,112 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( appendTurnItem(context, turnId, toolItem); return; } + case "mcp.oauth_required": { + const error = trimOrUndefined(event.data.wwwAuthenticateParams?.error); + const scope = trimOrUndefined(event.data.wwwAuthenticateParams?.scope); + context.pendingMcpOauthRequests.set(event.data.requestId, { + serverName: event.data.serverName, + ...(error ? { error } : {}), + }); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: event.data.requestId, + createdAt: event.timestamp, + }), + type: "mcp.status.updated", + payload: { + status: { + lifecycle: "oauth", + state: "required", + serverName: event.data.serverName, + reason: event.data.reason, + ...(scope ? { scope } : {}), + ...(error ? { error } : {}), + }, + }, + }); + return; + } + case "mcp.oauth_completed": { + const pending = context.pendingMcpOauthRequests.get(event.data.requestId); + context.pendingMcpOauthRequests.delete(event.data.requestId); + const success = event.data.outcome === "token"; + const name = trimOrUndefined(pending?.serverName); + const error = success + ? undefined + : (pending?.error ?? "Copilot MCP authentication was cancelled."); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: event.data.requestId, + createdAt: event.timestamp, + }), + type: "mcp.oauth.completed", + payload: { + success, + ...(name ? { name } : {}), + ...(error ? { error } : {}), + }, + }); + return; + } + case "mcp.headers_refresh_required": { + context.pendingMcpHeadersRefreshRequests.set(event.data.requestId, event.data.serverName); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: event.data.requestId, + createdAt: event.timestamp, + }), + type: "mcp.status.updated", + payload: { + status: { + lifecycle: "headers-refresh", + state: "required", + serverName: event.data.serverName, + reason: event.data.reason, + }, + }, + }); + return; + } + case "mcp.headers_refresh_completed": { + const serverName = trimOrUndefined( + context.pendingMcpHeadersRefreshRequests.get(event.data.requestId), + ); + context.pendingMcpHeadersRefreshRequests.delete(event.data.requestId); + await emitAsync({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: event.data.requestId, + createdAt: event.timestamp, + }), + type: "mcp.status.updated", + payload: { + status: { + lifecycle: "headers-refresh", + state: "completed", + outcome: event.data.outcome, + ...(serverName ? { serverName } : {}), + }, + }, + }); + return; + } case "permission.requested": { - if (event.data.resolvedByHook === true) { - return; - } const signature = permissionSignature(event.data.permissionRequest); + const pendingHandlers = context.pendingPermissionHandlersBySignature.get(signature); + if (event.data.resolvedByHook === true && !pendingHandlers?.length) { + const deferred = await runWithContext(Deferred.make()); + context.pendingPermissionHandlersBySignature.set(signature, [ + { + signature, + deferred, + resolvedByAdapter: true, + }, + ]); + } const queue = context.pendingPermissionEventsBySignature.get(signature) ?? []; queue.push(event.data); context.pendingPermissionEventsBySignature.set(signature, queue); @@ -2849,6 +3026,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } } } + await runWithContext( + Deferred.succeed(binding.deferred, event.data.result).pipe(Effect.ignore), + ); await runWithContext( emitPermissionRequestResolved( context, @@ -2942,8 +3122,17 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const contextTier = getModelSelectionStringOptionValue(modelSelection, "contextTier") as | CopilotContextTier | undefined; + const mcpSessionCandidate = McpProviderSession.readMcpProviderSession(input.threadId); + const mcpSession = + mcpSessionCandidate?.providerInstanceId === boundInstanceId + ? mcpSessionCandidate + : undefined; let context: CopilotSessionContext | undefined; const earlyEvents: Array = []; + const bootstrapPermissionRequests: Array<{ + readonly request: PermissionRequest; + readonly result: PermissionRequestResult; + }> = []; const onEvent: SessionConfig["onEvent"] = (event) => { if (!context) { earlyEvents.push(event); @@ -2951,16 +3140,15 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } enqueueSdkEvent(context, event); }; - const onSessionPermissionRequest = (_request: PermissionRequest) => { - return runWithContext( - context - ? onPermissionRequest(context, _request) - : Effect.succeed( - permissionAutoApprovedByRuntimeMode(input.runtimeMode, _request) - ? APPROVED_PERMISSION_RESULT - : DENIED_PERMISSION_RESULT, - ), - ); + const onSessionPermissionRequest = (request: PermissionRequest) => { + if (!context) { + const result = permissionAutoApprovedByRuntimeMode(input.runtimeMode, request) + ? APPROVED_PERMISSION_RESULT + : DENIED_PERMISSION_RESULT; + bootstrapPermissionRequests.push({ request, result }); + return runWithContext(Effect.succeed(result)); + } + return runWithContext(onPermissionRequest(context, request)); }; const onSessionUserInputRequest = (_request: CopilotUserInputRequest) => { return runWithContext( @@ -2999,7 +3187,23 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( workingDirectory: cwd, streaming: true, enableConfigDiscovery: true, + mcpOAuthTokenStorage: "in-memory", + ...(mcpSession + ? { + mcpServers: { + "t3-code": { + type: "http", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + } + : {}), onEvent, + onExitPlanModeRequest: DENY_EXIT_PLAN_MODE, + onAutoModeSwitchRequest: APPROVE_AUTO_MODE_SWITCH_ONCE, } satisfies Pick< SessionConfig, | "clientName" @@ -3009,7 +3213,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( | "workingDirectory" | "streaming" | "enableConfigDiscovery" + | "mcpOAuthTokenStorage" + | "mcpServers" | "onEvent" + | "onExitPlanModeRequest" + | "onAutoModeSwitchRequest" >; const createFreshSdkSession = () => @@ -3017,6 +3225,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...baseSessionConfig, sessionId: input.threadId, onPermissionRequest: onSessionPermissionRequest, + onMcpAuthRequest: makeMcpAuthHandler(input.threadId, input.threadId), onUserInputRequest: onSessionUserInputRequest, }); @@ -3028,6 +3237,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( .resumeSession(input.threadId, client, resume.sessionId, { ...baseSessionConfig, onPermissionRequest: onSessionPermissionRequest, + onMcpAuthRequest: makeMcpAuthHandler(input.threadId, resume.sessionId), onUserInputRequest: onSessionUserInputRequest, }) .pipe( @@ -3043,7 +3253,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } return yield* createFreshSdkSession(); }).pipe( - Effect.tapError(() => copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore)), + Effect.tapError(() => + copilotSdk.stopClient(input.threadId, client).pipe(Effect.ignore({ log: true })), + ), ); const historyMutationSemaphore = yield* Semaphore.make(1); @@ -3078,15 +3290,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( pendingUserInputHandlersBySignature: new Map(), pendingUserInputEventsBySignature: new Map(), pendingUserInputBindings: new Map(), + pendingMcpOauthRequests: new Map(), + pendingMcpHeadersRefreshRequests: new Map(), toolMetaById: new Map(), turnIdByProviderItemId: new Map(), emittedTextByItemId: new Map(), - assistantItemIdByTurnId: new Map(), + assistantItemIdsByTurnId: new Map(), pendingTaskCompletionTextByTurnId: new Map(), emittedTurnDiffByTurnId: new Map(), copilotTasks: new Map(), turnIdsWithAssistantText: new Set(), startedItemIds: new Set(), + completedAssistantItemIds: new Set(), turnEndEventsByTurnId: new Map(), turnEndFallbackTimers: new Map(), activeTurnId: undefined, @@ -3097,6 +3312,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( stopped: false, }; sessions.set(input.threadId, context); + for (const pending of bootstrapPermissionRequests) { + yield* onPermissionRequest(context, pending.request, pending.result); + } yield* syncSessionModeBestEffort( context, @@ -3172,22 +3390,22 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const reasoningEffort = rawReasoningEffort as CopilotReasoningEffort | undefined; const rawContextTier = getModelSelectionStringOptionValue(modelSelection, "contextTier"); const contextTier = rawContextTier as CopilotContextTier | undefined; - if (modelSelection?.model) { - yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort, contextTier); - updateProviderSession(context, { - model: modelSelection.model, - ...(reasoningEffort || contextTier ? { status: "ready" } : {}), - }); - } - const mode = requestedCopilotMode({ runtimeMode: context.session.runtimeMode, interactionMode: input.interactionMode, }); - yield* syncSessionMode(context, mode); return yield* context.historyMutationSemaphore.withPermit( Effect.gen(function* () { + if (modelSelection?.model) { + yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort, contextTier); + updateProviderSession(context, { + model: modelSelection.model, + ...(reasoningEffort || contextTier ? { status: "ready" } : {}), + }); + } + yield* syncSessionMode(context, mode); + const queuedAt = yield* DateTime.now; ensureTurnSnapshot(context, turnId); context.turnStartPayloadByTurnId.set(turnId, { @@ -3225,6 +3443,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( prompt: text ?? "", ...(attachments.length > 0 ? { attachments } : {}), mode: "enqueue", + agentMode: mode, }; const providerMessageId = yield* copilotSdk.send(context, messageOptions).pipe( @@ -3289,8 +3508,14 @@ 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); + if (context.activeTurnId === undefined) { + return; + } + if (turnId !== undefined && turnId !== context.activeTurnId) { + return; + } yield* copilotSdk.abort(context); }, @@ -3415,7 +3640,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ); yield* settlePendingUserInputs(context); yield* copilotSdk.disconnect(context).pipe(Effect.ignore); - yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore); + yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore({ log: true })); updateProviderSession(context, { status: "closed", @@ -3519,16 +3744,27 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const nextLength = Math.max(0, context.turns.length - numTurns); const removedTurns = context.turns.slice(nextLength); let sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; - if (removedTurns.length > 0 && !sdkHistoryEventId) { + if (numTurns > context.turns.length || !sdkHistoryEventId) { const historyEvents = yield* copilotSdk.getHistoryEvents(context); - for (const event of historyEvents) { - if (event.type === "user.message") { - bindSdkUserMessageToTurn(context, event); - } + const rootUserMessages = historyEvents.filter(isRootSdkUserMessage); + if (rootUserMessages.length < numTurns) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: `Cannot roll back ${numTurns} Copilot turn${numTurns === 1 ? "" : "s"} because persisted history contains only ${rootUserMessages.length} root user message${rootUserMessages.length === 1 ? "" : "s"}.`, + }); + } + for (const event of rootUserMessages) { + bindSdkUserMessageToTurn(context, event); } - sdkHistoryEventId = removedTurns[0]?.sdkHistoryEventId; + const persistedBoundaryId = + rootUserMessages[rootUserMessages.length - numTurns]?.id; + sdkHistoryEventId = + numTurns > context.turns.length + ? persistedBoundaryId + : (removedTurns[0]?.sdkHistoryEventId ?? persistedBoundaryId); } - if (removedTurns.length > 0 && !sdkHistoryEventId) { + if (!sdkHistoryEventId) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "thread.rollback", @@ -3543,12 +3779,18 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.turns.splice(nextLength); const removedTurnIds = new Set(removedTurns.map((turn) => turn.id)); for (const turnId of removedTurnIds) { + const assistantItemIds = context.assistantItemIdsByTurnId.get(turnId); + for (const assistantItemId of assistantItemIds ?? []) { + context.emittedTextByItemId.delete(assistantItemId); + context.startedItemIds.delete(assistantItemId); + context.completedAssistantItemIds.delete(assistantItemId); + } context.turnQueuedAtMsByTurnId.delete(turnId); context.completedTurnIds.delete(turnId); context.emittedTurnStartedIds.delete(turnId); context.turnStartPayloadByTurnId.delete(turnId); context.turnUsageByTurnId.delete(turnId); - context.assistantItemIdByTurnId.delete(turnId); + context.assistantItemIdsByTurnId.delete(turnId); context.pendingTaskCompletionTextByTurnId.delete(turnId); context.emittedTurnDiffByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); @@ -3561,8 +3803,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } context.turnIdByProviderItemId.delete(providerItemId); context.emittedTextByItemId.delete(providerItemId); + context.emittedTextByItemId.delete(`copilot-message-${providerItemId}`); context.toolMetaById.delete(providerItemId); context.startedItemIds.delete(providerItemId); + context.startedItemIds.delete(`copilot-message-${providerItemId}`); + context.completedAssistantItemIds.delete(`copilot-message-${providerItemId}`); } return { @@ -3581,8 +3826,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const stopAll: CopilotAdapterShape["stopAll"] = () => Effect.gen(function* () { + const activeLifecycleThreadIds = yield* lifecycleLock.activeThreadIds; + const threadIds = new Set([...sessions.keys(), ...activeLifecycleThreadIds]); yield* Effect.forEach( - Array.from(sessions.keys()), + threadIds, (threadId) => withLifecycleLock(threadId, stopSessionInternal(threadId)), { concurrency: "unbounded", diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index e8638b91ecef..ee63c428e469 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -13,6 +13,9 @@ const runtimeMock = vi.hoisted(() => { const state = { listModelsError: null as Error | null, createClientError: null as Error | null, + stopErrors: [] as Error[], + stopCalls: 0, + forceStopCalls: 0, }; return { @@ -20,6 +23,9 @@ const runtimeMock = vi.hoisted(() => { reset() { state.listModelsError = null; state.createClientError = null; + state.stopErrors = []; + state.stopCalls = 0; + state.forceStopCalls = 0; }, }; }); @@ -36,7 +42,13 @@ vi.mock("../copilotRuntime.ts", async () => { } return Effect.succeed({ start: vi.fn(async () => undefined), - stop: vi.fn(async () => undefined), + stop: vi.fn(async () => { + runtimeMock.state.stopCalls += 1; + return runtimeMock.state.stopErrors; + }), + forceStop: vi.fn(async () => { + runtimeMock.state.forceStopCalls += 1; + }), getStatus: vi.fn(async () => ({ version: "1.0.32", protocolVersion: 3, @@ -124,4 +136,18 @@ describe("CopilotProvider status", () => { NodeAssert.equal(secondSnapshot.checkedAt, "2026-06-08T12:01:00.000Z"); }), ); + + it.effect("force stops the probe client when graceful cleanup is incomplete", () => + Effect.gen(function* () { + runtimeMock.state.stopErrors = [new Error("probe runtime remained alive")]; + + yield* checkCopilotProviderStatus({ + settings: defaultCopilotSettings, + cwd: process.cwd(), + }); + + NodeAssert.equal(runtimeMock.state.stopCalls, 1); + NodeAssert.equal(runtimeMock.state.forceStopCalls, 1); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index a4c9b2bd7738..235f45bdd522 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -10,6 +10,7 @@ import { createCopilotClient, formatCopilotProbeError, modelsFromCopilotSdk, + stopCopilotClient, toCopilotProbeError, versionFromCopilotStatus, } from "../copilotRuntime.ts"; @@ -149,10 +150,11 @@ export function checkCopilotProviderStatus(input: { }); }, catch: toCopilotProbeError, - }) - .pipe(Effect.timeout(Duration.seconds(30))) - .pipe(Effect.catch((cause) => Effect.succeed(fallback(cause)))), - (client) => Effect.promise(() => client.stop()).pipe(Effect.ignore({ log: true })), + }).pipe( + Effect.timeout(Duration.seconds(30)), + Effect.catch((cause) => Effect.succeed(fallback(cause))), + ), + (client) => stopCopilotClient(client).pipe(Effect.ignore({ log: true })), ).pipe(Effect.catch((cause) => Effect.succeed(fallback(cause)))); }); } diff --git a/apps/server/src/provider/Layers/CopilotToolClassification.ts b/apps/server/src/provider/Layers/CopilotToolClassification.ts index 1a41d5e67334..53d9e8974cf7 100644 --- a/apps/server/src/provider/Layers/CopilotToolClassification.ts +++ b/apps/server/src/provider/Layers/CopilotToolClassification.ts @@ -107,10 +107,6 @@ export function classifyCopilotToolItemType(input: { 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") diff --git a/apps/server/src/provider/copilotMcpBearerAuth.ts b/apps/server/src/provider/copilotMcpBearerAuth.ts new file mode 100644 index 000000000000..9d7ad2b18a68 --- /dev/null +++ b/apps/server/src/provider/copilotMcpBearerAuth.ts @@ -0,0 +1,43 @@ +import type { McpProviderSessionConfig } from "../mcp/McpProviderSession.ts"; + +export interface CopilotMcpBearerAuth { + readonly accessToken: string; + readonly tokenType: "Bearer"; +} + +function normalizeCopilotMcpEndpoint(raw: string): string | undefined { + try { + const url = new URL(raw); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username.length > 0 || + url.password.length > 0 || + url.hash.length > 0 + ) { + return undefined; + } + if (url.pathname.length > 1) { + url.pathname = url.pathname.replace(/\/+$/, ""); + } + return url.href; + } catch { + return undefined; + } +} + +export function resolveCopilotMcpBearerAuth( + config: McpProviderSessionConfig | undefined, + serverUrl: string, +): CopilotMcpBearerAuth | undefined { + if ( + !config || + normalizeCopilotMcpEndpoint(config.endpoint) === undefined || + normalizeCopilotMcpEndpoint(config.endpoint) !== normalizeCopilotMcpEndpoint(serverUrl) + ) { + return undefined; + } + + const match = /^Bearer[ \t]+(\S+)$/i.exec(config.authorizationHeader); + const accessToken = match?.[1]; + return accessToken ? { accessToken, tokenType: "Bearer" } : undefined; +} diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 75f71dc5a80c..52109d11f6d1 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -10,6 +10,8 @@ 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 * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { authSnapshotFromCopilotSdk, @@ -20,6 +22,7 @@ import { modelsFromCopilotSdk, normalizeCopilotRuntimeEnvironment, resolveBundledCopilotCliPath, + stopCopilotClient, } from "./copilotRuntime.ts"; function assertStdioConnection(connection: CopilotClientOptions["connection"]) { @@ -29,7 +32,92 @@ function assertStdioConnection(connection: CopilotClientOptions["connection"]) { const POSIX_SHELL_FALLBACKS = ["/bin/bash", "/usr/bin/bash", "/bin/sh"] as const; +describe("stopCopilotClient", () => { + it.effect("force stops and surfaces cleanup errors returned by the SDK", () => + Effect.gen(function* () { + let forceStopCalls = 0; + const cleanupError = new Error("runtime shutdown timed out"); + + const error = yield* stopCopilotClient({ + stop: async () => [cleanupError], + forceStop: async () => { + forceStopCalls += 1; + }, + }).pipe(Effect.flip); + + NodeAssert.equal(forceStopCalls, 1); + NodeAssert.deepStrictEqual(error.cleanupErrors, [cleanupError]); + NodeAssert.match(error.message, /runtime shutdown timed out/); + }), + ); + + it.effect("does not force stop after clean SDK shutdown", () => + Effect.gen(function* () { + let forceStopCalls = 0; + + yield* stopCopilotClient({ + stop: async () => [], + forceStop: async () => { + forceStopCalls += 1; + }, + }); + + NodeAssert.equal(forceStopCalls, 0); + }), + ); + + it.effect("force stops when graceful SDK shutdown does not settle", () => + Effect.gen(function* () { + let forceStopCalls = 0; + const stopFiber = yield* stopCopilotClient({ + stop: () => new Promise(() => undefined), + forceStop: async () => { + forceStopCalls += 1; + }, + }).pipe(Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(stopFiber); + + NodeAssert.equal(forceStopCalls, 1); + NodeAssert.match(error.message, /timed out after 5 seconds/); + NodeAssert.match(String(error.stopCause), /timed out after 5 seconds/); + }), + ); +}); + describe("buildCopilotClientOptions", () => { + it.effect("only passes URI-compatible options to remote Copilot runtimes", () => + Effect.gen(function* () { + const onListModels = () => []; + const options = yield* buildCopilotClientOptions({ + settings: { + enabled: true, + binaryPath: "/ignored/copilot", + serverUrl: "http://127.0.0.1:4321", + customModels: [], + }, + cwd: "/ignored/project", + baseDirectory: "/ignored/home", + env: { COPILOT_CLI_PATH: "/ignored/copilot" }, + platform: "darwin", + logLevel: "error", + onListModels, + }); + + NodeAssert.deepEqual(options, { + connection: { + kind: "uri", + url: "http://127.0.0.1:4321", + connectionToken: undefined, + }, + logLevel: "error", + onListModels, + }); + }), + ); + it("leaves POSIX PATH hydration to the shared server environment setup", () => { const env = normalizeCopilotRuntimeEnvironment({ PATH: "/custom/bin:/bin" }, "darwin"); @@ -274,7 +362,6 @@ describe("buildCopilotClientOptions", () => { 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"); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 29bfeaa3156f..992c61ae2fca 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -53,6 +53,7 @@ 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; +const COPILOT_GRACEFUL_STOP_TIMEOUT = "5 seconds"; export class CopilotProbePromiseError extends Schema.TaggedErrorClass()( "CopilotProbePromiseError", @@ -65,6 +66,104 @@ export class CopilotProbePromiseError extends Schema.TaggedErrorClass; + readonly stopCause: unknown | undefined; + readonly forceStopCause: unknown | undefined; + + constructor(input: { + readonly cleanupErrors?: ReadonlyArray; + readonly stopCause?: unknown; + readonly forceStopCause?: unknown; + }) { + const cleanupErrors = input.cleanupErrors ?? []; + const details = [ + ...cleanupErrors.map((error) => error.message), + input.stopCause === undefined + ? undefined + : describeCopilotStopCause(input.stopCause, "Graceful stop rejected."), + input.forceStopCause === undefined + ? undefined + : describeCopilotStopCause(input.forceStopCause, "Force stop rejected."), + ].filter((detail): detail is string => detail !== undefined); + super( + details.length > 0 + ? `Copilot client cleanup was incomplete: ${details.join(" ")}` + : "Copilot client cleanup was incomplete.", + ); + this.name = "CopilotClientStopError"; + this.cleanupErrors = cleanupErrors; + this.stopCause = input.stopCause; + this.forceStopCause = input.forceStopCause; + } +} + +class CopilotClientStopPromiseError extends Schema.TaggedErrorClass()( + "CopilotClientStopPromiseError", + { + operation: Schema.Literals(["stop", "forceStop"]), + cause: Schema.Defect(), + }, +) {} + +function describeCopilotStopCause(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message.trim(); + } + if (typeof cause === "string" && cause.trim().length > 0) { + return cause.trim(); + } + return fallback; +} + +export const stopCopilotClient = Effect.fn("stopCopilotClient")(function* ( + client: Pick, +) { + const stopAttempt = yield* Effect.tryPromise({ + try: () => client.stop(), + catch: (cause) => new CopilotClientStopPromiseError({ operation: "stop", cause }), + }).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Rejected" as const, cause: error.cause }), + onSuccess: (cleanupErrors) => ({ _tag: "Resolved" as const, cleanupErrors }), + }), + Effect.timeoutOrElse({ + duration: COPILOT_GRACEFUL_STOP_TIMEOUT, + orElse: () => + Effect.succeed({ + _tag: "Rejected" as const, + cause: new Error( + `Graceful Copilot client stop timed out after ${COPILOT_GRACEFUL_STOP_TIMEOUT}.`, + ), + }), + }), + ); + + if (stopAttempt._tag === "Resolved" && stopAttempt.cleanupErrors.length === 0) { + return; + } + + const forceStopAttempt = yield* Effect.tryPromise({ + try: () => client.forceStop(), + catch: (cause) => new CopilotClientStopPromiseError({ operation: "forceStop", cause }), + }).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Rejected" as const, cause: error.cause }), + onSuccess: () => ({ _tag: "Resolved" as const }), + }), + ); + + return yield* Effect.fail( + new CopilotClientStopError({ + ...(stopAttempt._tag === "Resolved" + ? { cleanupErrors: stopAttempt.cleanupErrors } + : { stopCause: stopAttempt.cause }), + ...(forceStopAttempt._tag === "Rejected" ? { forceStopCause: forceStopAttempt.cause } : {}), + }), + ); +}); + class CopilotCliPathResolutionError extends Schema.TaggedErrorClass()( "CopilotCliPathResolutionError", { @@ -375,6 +474,14 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( readonly onListModels?: CopilotClientOptions["onListModels"]; }): Effect.fn.Return { const cliUrl = trimOrUndefined(input.settings.serverUrl); + if (cliUrl) { + return { + connection: RuntimeConnection.forUri(cliUrl), + ...(input.logLevel ? { logLevel: input.logLevel } : {}), + ...(input.onListModels ? { onListModels: input.onListModels } : {}), + }; + } + let env: Record = { ...process.env }; if (input.env) { @@ -392,32 +499,17 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( env, platform: input.platform, }); - const bundledCliPath = - !cliUrl && !configuredCliPath - ? yield* resolveBundledCopilotCliPath({ - ...(input.cwd ? { cwd: input.cwd } : {}), - env, - platform: input.platform, - }) - : undefined; - const cliPath = configuredCliPath ?? bundledCliPath; - const connection = cliUrl - ? yield* Effect.try({ - try: () => RuntimeConnection.forUri(cliUrl), - catch: (cause) => - copilotClientConfigurationError({ - settings: input.settings, - detail: "Invalid Copilot server URL.", - cause, - }), + const bundledCliPath = !configuredCliPath + ? yield* resolveBundledCopilotCliPath({ + ...(input.cwd ? { cwd: input.cwd } : {}), + env, + platform: input.platform, }) - : cliPath - ? RuntimeConnection.forStdio({ path: cliPath }) - : undefined; + : undefined; + const cliPath = configuredCliPath ?? bundledCliPath; return { - ...(connection ? { connection } : {}), - mode: "copilot-cli", + ...(cliPath ? { connection: RuntimeConnection.forStdio({ path: cliPath }) } : {}), ...(input.cwd ? { workingDirectory: input.cwd } : {}), ...(input.baseDirectory ? { baseDirectory: input.baseDirectory } : {}), env, diff --git a/apps/server/src/provider/threadLifecycleLock.ts b/apps/server/src/provider/threadLifecycleLock.ts new file mode 100644 index 000000000000..a8bcf3aa646b --- /dev/null +++ b/apps/server/src/provider/threadLifecycleLock.ts @@ -0,0 +1,61 @@ +import { type ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +interface LifecycleLock { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +} + +export const makeThreadLifecycleLock = Effect.fn("makeThreadLifecycleLock")(function* () { + const locksRef = yield* SynchronizedRef.make(new Map()); + + const acquire = (threadId: ThreadId) => + SynchronizedRef.modifyEffect(locksRef, (current) => { + const existing = current.get(threadId); + if (existing) { + const next = new Map(current); + next.set(threadId, { semaphore: existing.semaphore, users: existing.users + 1 }); + return Effect.succeed([existing.semaphore, next] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, { semaphore, users: 1 }); + return [semaphore, next] as const; + }), + ); + }); + + const release = (threadId: ThreadId, semaphore: Semaphore.Semaphore) => + SynchronizedRef.update(locksRef, (current) => { + const lock = current.get(threadId); + if (!lock || lock.semaphore !== semaphore) { + return current; + } + const next = new Map(current); + if (lock.users === 1) { + next.delete(threadId); + } else { + next.set(threadId, { semaphore, users: lock.users - 1 }); + } + return next; + }); + + const withLock = (threadId: ThreadId, effect: Effect.Effect) => + Effect.acquireUseRelease( + acquire(threadId), + (semaphore) => semaphore.withPermit(effect), + (semaphore) => release(threadId, semaphore), + ); + + const activeThreadIds = SynchronizedRef.get(locksRef).pipe( + Effect.map((locks) => new Set(locks.keys())), + ); + + return { + withLock, + activeThreadIds, + } as const; +}); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 16f4b6c9f5be..8e01c7b0dba4 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -5,6 +5,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { ServerConfig } from "../config.ts"; @@ -17,6 +18,7 @@ const runtimeMock = vi.hoisted(() => { readonly client: { readonly start: ReturnType; readonly stop: ReturnType; + readonly forceStop: ReturnType; readonly createSession: ReturnType; }; }>, @@ -26,6 +28,8 @@ const runtimeMock = vi.hoisted(() => { readonly sendAndWait: ReturnType; }>, clientStartGate: null as Promise | null, + clientStartError: null as Error | null, + stopErrors: [] as Error[], }; return { @@ -35,6 +39,8 @@ const runtimeMock = vi.hoisted(() => { state.sessionConfigs = []; state.sessions = []; state.clientStartGate = null; + state.clientStartError = null; + state.stopErrors = []; }, }; }); @@ -50,8 +56,12 @@ vi.mock("../provider/copilotRuntime.ts", async () => { (input: { readonly cwd?: string; readonly baseDirectory?: string }) => { const start = vi.fn(async () => { await runtimeMock.state.clientStartGate; + if (runtimeMock.state.clientStartError) { + throw runtimeMock.state.clientStartError; + } }); - const stop = vi.fn(async () => undefined); + const stop = vi.fn(async () => runtimeMock.state.stopErrors); + const forceStop = vi.fn(async () => undefined); const createSession = vi.fn(async (config: unknown) => { runtimeMock.state.sessionConfigs.push(config); const sendAndWait = vi.fn(async () => ({ @@ -73,6 +83,7 @@ vi.mock("../provider/copilotRuntime.ts", async () => { const client = { start, stop, + forceStop, createSession, }; runtimeMock.state.createdClients.push({ input, client }); @@ -175,6 +186,76 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { }), ); + it.effect("stops failed clients and allows a later Copilot startup retry", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + runtimeMock.state.clientStartError = new Error("Copilot startup failed"); + const request = () => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-startup-failure", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + const firstResult = yield* request().pipe(Effect.result); + expect(firstResult._tag).toBe("Failure"); + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.createdClients[0]?.client.stop).toHaveBeenCalledOnce(); + + runtimeMock.state.clientStartError = null; + const retry = yield* request(); + expect(retry.subject).toBe("Add change"); + expect(runtimeMock.state.createdClients).toHaveLength(2); + expect(runtimeMock.state.createdClients[1]?.client.start).toHaveBeenCalledOnce(); + }), + ); + + it.effect("unblocks waiters and cleans up when shared Copilot startup is interrupted", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + let releaseClientStart!: () => void; + runtimeMock.state.clientStartGate = new Promise((resolve) => { + releaseClientStart = resolve; + }); + const request = () => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-startup-interruption", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + + const startingFiber = yield* request().pipe(Effect.forkChild); + for ( + let attempt = 0; + attempt < 20 && runtimeMock.state.createdClients.length === 0; + attempt += 1 + ) { + yield* Effect.yieldNow; + } + expect(runtimeMock.state.createdClients).toHaveLength(1); + + const waitingFiber = yield* request().pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(startingFiber); + const waitingResult = yield* Fiber.join(waitingFiber); + + expect(waitingResult._tag).toBe("Failure"); + expect(runtimeMock.state.createdClients[0]?.client.stop).toHaveBeenCalledOnce(); + + runtimeMock.state.clientStartGate = null; + releaseClientStart(); + const retry = yield* request(); + expect(retry.subject).toBe("Add change"); + expect(runtimeMock.state.createdClients).toHaveLength(2); + }), + ); + it.effect("passes the configured Copilot base directory to shared clients", () => Effect.gen(function* () { const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings, process.env, { @@ -216,11 +297,34 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { model: "gpt-4.1", reasoningEffort: "high", contextTier: "long_context", + infiniteSessions: { enabled: false }, }, ]); }), ); + it.effect("force stops an idle shared client after incomplete graceful cleanup", () => + Effect.gen(function* () { + const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); + const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + runtimeMock.state.stopErrors = [new Error("text client cleanup failed")]; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/copilot-cleanup", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection, + }); + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + + const client = runtimeMock.state.createdClients[0]?.client; + expect(client?.stop).toHaveBeenCalledOnce(); + expect(client?.forceStop).toHaveBeenCalledOnce(); + }), + ); + it.effect("generates local thread title fallbacks without starting a Copilot SDK session", () => Effect.gen(function* () { const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index a5d4ea0d60e0..13902513d353 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -1,4 +1,5 @@ import type { CopilotClient, CopilotSession, SessionConfig } from "@github/copilot-sdk"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -20,7 +21,11 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; -import { createCopilotClient, trimOrUndefined } from "../provider/copilotRuntime.ts"; +import { + createCopilotClient, + stopCopilotClient, + trimOrUndefined, +} from "../provider/copilotRuntime.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -139,10 +144,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( if (idleCloseFiber !== null) { yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); } - yield* Effect.tryPromise({ - try: () => state.client.stop(), - catch: () => undefined, - }).pipe(Effect.ignore); + yield* stopCopilotClient(state.client).pipe(Effect.ignore({ log: true })); }); const cancelIdleCloseFiber = (state: SharedCopilotTextClientState) => @@ -235,12 +237,10 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( } const startupDeferred = acquisition.deferred; - - // Start the client. If any step fails we must fail and remove - // startupDeferred so pending waiters are unblocked rather than - // hanging forever. + let newClient: CopilotClient | undefined; + let installed = false; const client = yield* Effect.gen(function* () { - const newClient = yield* createCopilotClient({ + const createdClient = yield* createCopilotClient({ settings: input.settings, cwd: input.cwd, binaryPathBaseDirectory: serverConfig.cwd, @@ -258,60 +258,59 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( }), ), ); + newClient = createdClient; - yield* Effect.uninterruptibleMask((restore) => + yield* Effect.tryPromise({ + try: () => createdClient.start(), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Failed to start Copilot client."), + cause, + }), + }).pipe(Effect.interruptible); + + yield* sharedClientMutex.withPermit( Effect.gen(function* () { - yield* restore( - Effect.tryPromise({ - try: (signal) => - new Promise((resolve, reject) => { - const abort = () => { - void newClient.stop().catch(() => undefined); - reject(signal.reason ?? new Error("Copilot client startup interrupted.")); - }; - if (signal.aborted) { - abort(); - return; - } - signal.addEventListener("abort", abort, { once: true }); - newClient - .start() - .then(resolve, reject) - .finally(() => { - signal.removeEventListener("abort", abort); - }); - }), - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: detailFromCause(cause, "Failed to start Copilot client."), - cause, - }), - }), - ); + pendingClients.delete(clientKey); + sharedClients.set(clientKey, { + client: createdClient, + activeRequests: 1, + idleCloseFiber: null, + }); + installed = true; + yield* Deferred.succeed(startupDeferred, createdClient); + }), + ); + return createdClient; + }).pipe( + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.void; + } + const cause = Cause.squash(exit.cause); + const error = isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: detailFromCause(cause, "Copilot client startup was interrupted."), + cause, + }); + return Effect.gen(function* () { yield* sharedClientMutex.withPermit( Effect.gen(function* () { - pendingClients.delete(clientKey); - sharedClients.set(clientKey, { - client: newClient, - activeRequests: 1, - idleCloseFiber: null, - }); - yield* Deferred.succeed(startupDeferred, newClient); + if (pendingClients.get(clientKey) === startupDeferred) { + pendingClients.delete(clientKey); + } + yield* Deferred.fail(startupDeferred, error); }), ); - }), - ); - - return newClient; - }).pipe( - Effect.tapError((error) => - Effect.gen(function* () { - pendingClients.delete(clientKey); - yield* Deferred.fail(startupDeferred, error); - }), - ), + if (newClient && !installed) { + yield* stopCopilotClient(newClient).pipe(Effect.ignore({ log: true })); + } + }); + }), ); return { clientKey, client }; }); @@ -398,9 +397,7 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( streaming: false, availableTools: [], enableConfigDiscovery: false, - onPermissionRequest: () => ({ - kind: "denied-no-approval-rule-and-could-not-request-from-user", - }), + infiniteSessions: { enabled: false }, }), catch: (cause) => new TextGenerationError({ From b41a327e223276d21d0a7904bbac93c2b666ec53 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 15:11:01 +0200 Subject: [PATCH 138/155] fix(copilot): complete failed tool turns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 273 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 39 ++- 2 files changed, 306 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 7c13d8e9090f..5e68a734922e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -5609,6 +5609,238 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("keeps a Copilot turn running while a subagent executes", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-subagent-running-lifecycle"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "delegate this work to a subagent", + 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-subagent-root-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-subagent-root", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-root-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-subagent-root", + content: "I am delegating this work.", + turnId: "sdk-turn-subagent-root", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-root-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-subagent-root", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-subagent", + toolName: "Task", + turnId: "sdk-turn-subagent-root", + arguments: { + description: "Investigate the delegated work", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-started", + timestamp, + parentId: null, + type: "subagent.started", + agentId: "subagent-1", + data: { + agentDescription: "Investigates delegated work", + agentDisplayName: "Research agent", + agentName: "research", + toolCallId: "tool-subagent", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-turn-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + agentId: "subagent-1", + data: { + turnId: "sdk-turn-subagent", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-message", + timestamp, + parentId: null, + type: "assistant.message", + agentId: "subagent-1", + data: { + messageId: "message-subagent", + content: "The delegated investigation is still running.", + parentToolCallId: "tool-subagent", + turnId: "sdk-turn-subagent", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-turn-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + agentId: "subagent-1", + data: { + turnId: "sdk-turn-subagent", + }, + } as SessionEvent); + + yield* TestClock.adjust("25 millis"); + yield* waitForSdkEventQueue(); + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "turn.completed"), + false, + ); + + emit({ + id: "evt-copilot-subagent-completed", + timestamp, + parentId: null, + type: "subagent.completed", + agentId: "subagent-1", + data: { + agentDisplayName: "Research agent", + agentName: "research", + toolCallId: "tool-subagent", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-subagent", + turnId: "sdk-turn-subagent-root", + success: true, + result: { + content: "Delegated investigation completed.", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-intermediate-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + yield* TestClock.adjust("300 millis"); + yield* waitForSdkEventQueue(); + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "turn.completed"), + false, + ); + + emit({ + id: "evt-copilot-subagent-final-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-subagent-final", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-final-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-subagent-final", + content: "The delegated work is complete.", + turnId: "sdk-turn-subagent-final", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-final-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-subagent-final", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-subagent-final-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + + yield* TestClock.adjust("300 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); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores unmapped sdk turn starts without synthesizing a turn id", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -6301,7 +6533,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("maps the next turn correctly after first completion clears stale sdk turn state", () => + it.effect("maps the next turn after a failed tool completes the first turn on idle", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-idle-only-next-turn-mapping"); @@ -6339,6 +6571,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-idle-only-first", }, } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-first-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-idle-only-first", + content: "I will try a tool.", + turnId: "sdk-turn-idle-only-first", + }, + } as SessionEvent); emit({ id: "evt-copilot-idle-only-first-end", timestamp, @@ -6348,6 +6591,34 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-idle-only-first", }, } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-failed-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-idle-only-failure", + toolName: "shell", + turnId: "sdk-turn-idle-only-first", + arguments: { + command: "false", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-idle-only-failed-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-idle-only-failure", + turnId: "sdk-turn-idle-only-first", + success: false, + error: { + message: "Command failed", + }, + }, + } as SessionEvent); emit({ id: "evt-copilot-idle-only-first-idle", timestamp, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 0947857f441d..92244fee225c 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -222,6 +222,7 @@ interface CopilotSessionContext { readonly emittedTurnDiffByTurnId: Map; readonly copilotTasks: Map; readonly turnIdsWithAssistantText: Set; + readonly turnIdsWithRootAssistantTextSinceToolStart: Set; readonly startedItemIds: Set; readonly completedAssistantItemIds: Set; readonly turnEndEventsByTurnId: Map; @@ -1562,6 +1563,12 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } } + const markTurnContinuingWithTool = (context: CopilotSessionContext, turnId: TurnId): void => { + cancelTurnEndFallback(context, turnId); + context.turnEndEventsByTurnId.delete(turnId); + context.turnIdsWithRootAssistantTextSinceToolStart.delete(turnId); + }; + const completeAssistantTextItem = async (input: { readonly context: CopilotSessionContext; readonly turnId: TurnId; @@ -1637,6 +1644,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.turnEndEventsByTurnId.delete(turnId); context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); + context.turnIdsWithRootAssistantTextSinceToolStart.delete(turnId); context.turnStartPayloadByTurnId.delete(turnId); clearSdkTurnMappingsForTurn(context, turnId); if (context.activeTurnId === turnId) { @@ -1676,7 +1684,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const hasTurnCompletionSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => context.turnEndEventsByTurnId.has(turnId) || - context.turnIdsWithAssistantText.has(turnId) || + context.turnIdsWithRootAssistantTextSinceToolStart.has(turnId) || context.pendingTaskCompletionTextByTurnId.has(turnId); const shouldCompleteOnAssistantTurnEnd = ( @@ -1684,7 +1692,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnId: TurnId, ): boolean => context.activeTurnId === turnId && - (context.turnIdsWithAssistantText.has(turnId) || + (context.turnIdsWithRootAssistantTextSinceToolStart.has(turnId) || context.pendingTaskCompletionTextByTurnId.has(turnId)); const shouldCompleteOnIdleSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => @@ -1803,6 +1811,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( readonly turnId: TurnId; readonly itemId: string; readonly nextText: string; + readonly marksTurnCompletion?: boolean | undefined; readonly raw?: SessionEvent | undefined; }) => { if (!input.context.startedItemIds.has(input.itemId)) { @@ -1833,6 +1842,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } input.context.turnIdsWithAssistantText.add(input.turnId); + if (input.marksTurnCompletion !== false) { + input.context.turnIdsWithRootAssistantTextSinceToolStart.add(input.turnId); + } await emitAsync({ ...createBaseEvent({ threadId: input.context.threadId, @@ -2622,6 +2634,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnId, itemId, nextText: (context.emittedTextByItemId.get(itemId) ?? "") + event.data.deltaContent, + marksTurnCompletion: event.agentId === undefined, raw: event, }); return; @@ -2644,6 +2657,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( turnId, itemId, nextText: event.data.content, + marksTurnCompletion: event.agentId === undefined, raw: event, }); await completeAssistantTextItem({ @@ -2662,12 +2676,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 (event.agentId !== undefined) { + return; + } + context.turnEndEventsByTurnId.set(turnId, event); + const shouldComplete = shouldCompleteOnAssistantTurnEnd(context, turnId); if (shouldComplete) { scheduleTurnEndFallback(context, turnId, event); } @@ -2720,6 +2737,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!turnId) { return; } + markTurnContinuingWithTool(context, turnId); const todoPlan = isTodoTool(event.data.toolName) && isStringRecord(event.data.arguments) ? extractPlanStepsFromTodoInput(event.data.arguments) @@ -2839,8 +2857,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( trimOrUndefined(event.data.result?.content) ?? trimOrUndefined(event.data.error?.message); const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); + if (event.agentId === undefined && !event.data.success) { + context.turnEndEventsByTurnId.set(turnId, event); + } if (isTaskCompleteTool(toolMeta?.toolName)) { - if (event.data.success && detail) { + if (event.agentId === undefined && event.data.success && detail) { context.pendingTaskCompletionTextByTurnId.set(turnId, detail); } return; @@ -2889,6 +2910,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( appendTurnItem(context, turnId, toolItem); return; } + case "subagent.started": { + const turnId = context.turnIdByProviderItemId.get(event.data.toolCallId); + if (turnId) { + markTurnContinuingWithTool(context, turnId); + } + return; + } case "mcp.oauth_required": { const error = trimOrUndefined(event.data.wwwAuthenticateParams?.error); const scope = trimOrUndefined(event.data.wwwAuthenticateParams?.scope); @@ -3300,6 +3328,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( emittedTurnDiffByTurnId: new Map(), copilotTasks: new Map(), turnIdsWithAssistantText: new Set(), + turnIdsWithRootAssistantTextSinceToolStart: new Set(), startedItemIds: new Set(), completedAssistantItemIds: new Set(), turnEndEventsByTurnId: new Map(), From 2c07a9328958fb3aac52588f427129c2002a4408 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 15:11:06 +0200 Subject: [PATCH 139/155] fix(providers): recover stale instance selections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/serverSettings.test.ts | 26 ++++++++++ apps/server/src/serverSettings.ts | 18 +++++++ apps/web/src/components/chat/ChatComposer.tsx | 17 ++++--- apps/web/src/providerInstances.test.ts | 50 +++++++++++++++---- apps/web/src/providerInstances.ts | 7 +-- 5 files changed, 95 insertions(+), 23 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18def..32251e45f428 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -298,6 +298,32 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("falls back to an enabled custom instance before legacy providers", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const fallbackInstanceId = ProviderInstanceId.make("copilot_personal"); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [fallbackInstanceId]: { + driver: ProviderDriverKind.make("copilot"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("copilot_deleted"), + model: "gpt-4.1", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: fallbackInstanceId, + model: "gpt-4.1", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2b928b00d502..def26775e128 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -206,6 +206,24 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { + const fallbackInstanceEntry = Object.entries(settings.providerInstances).find( + ([, instance]) => instance.enabled ?? true, + ); + if (fallbackInstanceEntry) { + const [instanceId, instance] = fallbackInstanceEntry; + const driver = instance.driver; + return { + ...settings, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make(instanceId), + model: + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_GIT_TEXT_GENERATION_MODEL, + } satisfies ModelSelection, + }; + } + const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6ec2e631d198..58136f3102b0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -110,6 +110,7 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, @@ -730,7 +731,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -745,19 +746,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return match.instanceId; } } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } const byKind = providerInstanceEntries.find( (entry) => entry.enabled && + entry.isAvailable && entry.driverKind === selectedProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const selectableEntries = providerInstanceEntries.filter( + (entry) => + (!lockedProvider || entry.driverKind === lockedProvider) && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + ); + const selectableInstanceId = resolveSelectableProviderInstance(selectableEntries, undefined); return ( - anyEnabled?.instanceId ?? + selectableInstanceId ?? providerInstanceEntries[0]?.instanceId ?? activeThreadModelSelection?.instanceId ?? activeProjectDefaultModelSelection?.instanceId ?? @@ -768,7 +772,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a5b03f563281..b71f2aea4fa2 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -123,34 +123,62 @@ describe("deriveProviderInstanceEntries", () => { describe("resolveSelectableProviderInstance", () => { it("returns the requested instance when it is enabled and available", () => { const requested = ProviderInstanceId.make("claude_work"); - const providers = [ + const entries = deriveProviderInstanceEntries([ provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: requested }), - ]; + ]); - expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested); + expect(resolveSelectableProviderInstance(entries, requested)).toBe(requested); }); it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); const fallback = ProviderInstanceId.make("claudeAgent"); - const providers = [ + const entries = deriveProviderInstanceEntries([ provider({ provider: ProviderDriverKind.make("codex"), instanceId: disabled, enabled: false, }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: fallback }), - ]; + ]); - expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); + expect(resolveSelectableProviderInstance(entries, disabled)).toBe(fallback); + }); + + it("falls back from a removed custom Copilot instance to a selectable instance", () => { + const removed = ProviderInstanceId.make("copilot_work"); + const fallback = ProviderInstanceId.make("copilot_personal"); + const entries = applyProviderInstanceSettings( + deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("copilot"), + instanceId: removed, + }), + provider({ + provider: ProviderDriverKind.make("copilot"), + instanceId: fallback, + }), + ]), + { + providerInstances: { + [fallback]: { + driver: ProviderDriverKind.make("copilot"), + enabled: true, + }, + }, + providers: {} as never, + }, + ); + + expect(resolveSelectableProviderInstance(entries, removed)).toBe(fallback); }); it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { const disabled = ProviderInstanceId.make("codex"); const unavailable = ProviderInstanceId.make("claudeAgent"); const unknown = ProviderInstanceId.make("removed_instance"); - const providers = [ + const entries = deriveProviderInstanceEntries([ provider({ provider: ProviderDriverKind.make("codex"), instanceId: disabled, @@ -161,11 +189,11 @@ describe("resolveSelectableProviderInstance", () => { instanceId: unavailable, availability: "unavailable", }), - ]; + ]); - expect(resolveSelectableProviderInstance(providers, disabled)).toBeUndefined(); - expect(resolveSelectableProviderInstance(providers, unavailable)).toBeUndefined(); - expect(resolveSelectableProviderInstance(providers, unknown)).toBeUndefined(); + expect(resolveSelectableProviderInstance(entries, disabled)).toBeUndefined(); + expect(resolveSelectableProviderInstance(entries, unavailable)).toBeUndefined(); + expect(resolveSelectableProviderInstance(entries, unknown)).toBeUndefined(); }); }); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c9ac87ac39f1..63d61b6076af 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -260,15 +260,12 @@ export function getProviderInstanceModels( * as a fallback so downstream code can still send a turn. */ export function resolveSelectableProviderInstance( - providers: ReadonlyArray, + entries: ReadonlyArray, instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { if (instanceId === undefined) { - return deriveProviderInstanceEntries(providers).find( - (entry) => entry.enabled && entry.isAvailable, - )?.instanceId; + return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; } - const entries = deriveProviderInstanceEntries(providers); const requested = entries.find((entry) => entry.instanceId === instanceId); if (requested && requested.enabled && requested.isAvailable) { return instanceId; From c11a46993437fc05c835991d0a2325a87fc55cc4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 15:11:10 +0200 Subject: [PATCH 140/155] fix(copilot): reduce work log noise Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 5 +++- apps/web/src/session-logic.test.ts | 38 ++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 7 +++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066a..16cd938cfd0a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1861,7 +1861,10 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const workLogEntries = useMemo( + () => deriveWorkLogEntries(threadActivities, selectedProvider), + [selectedProvider, threadActivities], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4a1b5ae13b87..0dae5df9ba85 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1,6 +1,7 @@ import { EventId, MessageId, + ProviderDriverKind, ThreadId, TurnId, type OrchestrationThreadActivity, @@ -691,6 +692,43 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { + it("hides noisy resolved approval and plan update rows for Copilot only", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-requested", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { requestId: "req-1", requestKind: "command" }, + }), + makeActivity({ + id: "approval-resolved", + kind: "approval.resolved", + summary: "Approval resolved", + tone: "approval", + payload: { requestId: "req-1", requestKind: "command" }, + }), + makeActivity({ + id: "plan-updated", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + payload: { + plan: [{ step: "Implement the change", status: "inProgress" }], + }, + }), + ]; + + expect( + deriveWorkLogEntries(activities, ProviderDriverKind.make("copilot")).map((entry) => entry.id), + ).toEqual(["approval-requested"]); + expect( + deriveWorkLogEntries(activities, ProviderDriverKind.make("codex")) + .map((entry) => entry.id) + .toSorted(), + ).toEqual(["approval-requested", "approval-resolved", "plan-updated"]); + }); + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 69b0aa967b2e..fc63bddfe762 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -627,10 +627,17 @@ export function hasActionableProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, + provider?: ProviderDriverKind, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if ( + provider === "copilot" && + (activity.kind === "approval.resolved" || activity.kind === "turn.plan.updated") + ) { + continue; + } if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; From 651ff6685c424d0c98b1f99409a48b69e6c7f55b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 15:11:16 +0200 Subject: [PATCH 141/155] chore(copilot): update CLI and SDK packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/package.json | 4 +- .../src/provider/Drivers/CopilotDriver.ts | 2 +- pnpm-lock.yaml | 86 +++++++++---------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 2ead40a36739..d7887b52e28d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,8 +28,8 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", - "@github/copilot": "1.0.68", - "@github/copilot-sdk": "1.0.5", + "@github/copilot": "1.0.69", + "@github/copilot-sdk": "1.0.6", "@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 a98a68ab3701..af5c87e4be63 100644 --- a/apps/server/src/provider/Drivers/CopilotDriver.ts +++ b/apps/server/src/provider/Drivers/CopilotDriver.ts @@ -96,7 +96,7 @@ export const CopilotDriver: ProviderDriver = }); const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, - packageName: "@github/copilot-sdk", + packageName: "@github/copilot", }); yield* fileSystem.makeDirectory(baseDirectory, { recursive: true }).pipe( Effect.mapError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4469fc7399ec..ee23a06b8c10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,11 +457,11 @@ importers: specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@github/copilot': - specifier: 1.0.68 - version: 1.0.68 + specifier: 1.0.69 + version: 1.0.69 '@github/copilot-sdk': - specifier: 1.0.5 - version: 1.0.5 + specifier: 1.0.6 + version: 1.0.6 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -2702,64 +2702,64 @@ packages: '@formkit/auto-animate@0.9.0': resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} - '@github/copilot-darwin-arm64@1.0.68': - resolution: {integrity: sha512-0G26AL9dlrwTa5IRTxPEnkX6Kz20CuetIwXzABmWwiXYcsR9rswM/NYICR3k53TOa0zL7aqFPnl98CW7J5XbZw==} + '@github/copilot-darwin-arm64@1.0.69': + resolution: {integrity: sha512-LpaVS2o21BbYTFUlkqdwUJXqE0l1Lp50Cb/EHru3z+5n5x7zNUU5IlPxdMg5gHMVw78clnMn2zSJbe5hYFyDDQ==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.68': - resolution: {integrity: sha512-RoClWH4CPH19pv5jrR0E6pBU6ljgrzL7idb7tV2pPtMYGTuwqW//XrIEE4n/5NVZmhzEiVBeq04THzOBeV493A==} + '@github/copilot-darwin-x64@1.0.69': + resolution: {integrity: sha512-QL5bsmnYqliBVIEE8RY91e6yAr39c2TNvJc/5IzoaKMlQ9MCrD9xakBJwd7LOS0SiOwRE4fqn8pUA4L1+UU5fg==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.68': - resolution: {integrity: sha512-SXSOz/2xPeUM/ndKypBiQv+QGYaEM7oGytl1i+Yx4tJnOoIwLkkTmIaWUbBNn0n5DTEjUcWyDqHzxxo/42FKRQ==} + '@github/copilot-linux-arm64@1.0.69': + resolution: {integrity: sha512-SOibp0I6APLoY2KkccbNOISpbhE3lp41s9fM3eANqfdoHQLsgBopfk4ruBP8k/DzjQA6t4MrbV8v6dLixAqv3w==} cpu: [arm64] os: [linux] libc: [glibc] hasBin: true - '@github/copilot-linux-x64@1.0.68': - resolution: {integrity: sha512-YdG1chniWyps7XEJ2YHUOJkcOc6BpDQZby/zOKCVdswzRXx7d3WiZ2P9lfDimBBmXXJEJ81Fqhv2ZK5eOmGlUw==} + '@github/copilot-linux-x64@1.0.69': + resolution: {integrity: sha512-URT7UaR3l1VAAtEpbKjybup2vxHTMSfDTVlu0jinu1O2nWxFOeQ9N4BQgRdcCB7wDu3N8Yz47o3fV1Gob6Z1Yw==} cpu: [x64] os: [linux] libc: [glibc] hasBin: true - '@github/copilot-linuxmusl-arm64@1.0.68': - resolution: {integrity: sha512-LTYZFOHpeLg4rCtsq3A/LMZxxRKFcCLmhnt8F7ovNYLNDJMkh3xdYanoXP0C0PO3uyUMiNJ+p5YXhZiBuo2yfw==} + '@github/copilot-linuxmusl-arm64@1.0.69': + resolution: {integrity: sha512-Y6DY9RkyWV6l+S0cNz4d+AkUa38goNV5StQH3j8OoVrndK3+RgQeUjG1XpJ//PzXEXi4vQ0cZuc9XzFj/TY7tw==} cpu: [arm64] os: [linux] libc: [musl] hasBin: true - '@github/copilot-linuxmusl-x64@1.0.68': - resolution: {integrity: sha512-oOMXZ9HPJAJaKSrXZIYuond4uOiUkK1uRhVPI3Cs74n7uEVKPWpNlTN+j/O754dnBa1+HJcuZSDMulTbnOgirg==} + '@github/copilot-linuxmusl-x64@1.0.69': + resolution: {integrity: sha512-h0aNgUWRu70Tgp4/7Vdqa/4XKJi96wP32R1PoKeXkz1QNxb6i/IQxhoCjjS+xFon9e2kefKdF8pDrDtUJjsiBQ==} cpu: [x64] os: [linux] libc: [musl] hasBin: true - '@github/copilot-sdk@1.0.5': - resolution: {integrity: sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==} + '@github/copilot-sdk@1.0.6': + resolution: {integrity: sha512-24eI5UNY3dWjGBNTLhNJf8ki3dNIE2o2jlEZv2F79N5MHzVpnDWbpM8vUI8L1furBXqc9dWRhLPJG/4Z7Me8Ow==} engines: {node: ^20.19.0 || >=22.12.0} - '@github/copilot-win32-arm64@1.0.68': - resolution: {integrity: sha512-ZDqpJMP9Y5vqwvRxnIvZrVl8ibx/P66m3JTXQuzv6pitq7rkMEuNKscZ7cjJYN4N+BCOF5++5LKw8O1WHzXAAA==} + '@github/copilot-win32-arm64@1.0.69': + resolution: {integrity: sha512-7DMoC1uwt01yZf48xq8MgKdnHacXabfsGsOizbgmrlZMfvQQofrF55x1Efu3BKsEKFGRl4fCLjJHJYCz/nWsHg==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.68': - resolution: {integrity: sha512-eplj/Y2B+amMLJ37oNE6G8gx85j8ucAuJz+CjzpzprNiBUq45lFL8ukGeDtaLMRvIeYAEDYdz5yUzu2XtCE7mA==} + '@github/copilot-win32-x64@1.0.69': + resolution: {integrity: sha512-5uD1/k6oVzqRhiS9jLrySGa20HjItqUJsaMe+bV6eYT7zQPF/74xihEjxMm3/bJcATW2SesiYQxyNzTcKm3qRA==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.68': - resolution: {integrity: sha512-2VPcTlW0RAEsfeS0Ma2ICCkfXgpxy3NL7+SReR8gzvEEPiokSRf0k5JBPlgMbBEFvocSRcJ01S8KvBm84Dw+Fw==} + '@github/copilot@1.0.69': + resolution: {integrity: sha512-sri6PKtRQu7mfok3eV505fmYo6M6PGZpB1vd+QbMEm2rEZaKE2+wJGCAtkpQNKfAWBvvoW/C94IaeFyyulUvqQ==} hasBin: true '@hono/node-server@1.19.14': @@ -12768,48 +12768,48 @@ snapshots: '@formkit/auto-animate@0.9.0': {} - '@github/copilot-darwin-arm64@1.0.68': + '@github/copilot-darwin-arm64@1.0.69': optional: true - '@github/copilot-darwin-x64@1.0.68': + '@github/copilot-darwin-x64@1.0.69': optional: true - '@github/copilot-linux-arm64@1.0.68': + '@github/copilot-linux-arm64@1.0.69': optional: true - '@github/copilot-linux-x64@1.0.68': + '@github/copilot-linux-x64@1.0.69': optional: true - '@github/copilot-linuxmusl-arm64@1.0.68': + '@github/copilot-linuxmusl-arm64@1.0.69': optional: true - '@github/copilot-linuxmusl-x64@1.0.68': + '@github/copilot-linuxmusl-x64@1.0.69': optional: true - '@github/copilot-sdk@1.0.5': + '@github/copilot-sdk@1.0.6': dependencies: - '@github/copilot': 1.0.68 + '@github/copilot': 1.0.69 vscode-jsonrpc: 8.2.1 zod: 4.4.3 - '@github/copilot-win32-arm64@1.0.68': + '@github/copilot-win32-arm64@1.0.69': optional: true - '@github/copilot-win32-x64@1.0.68': + '@github/copilot-win32-x64@1.0.69': optional: true - '@github/copilot@1.0.68': + '@github/copilot@1.0.69': 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 + '@github/copilot-darwin-arm64': 1.0.69 + '@github/copilot-darwin-x64': 1.0.69 + '@github/copilot-linux-arm64': 1.0.69 + '@github/copilot-linux-x64': 1.0.69 + '@github/copilot-linuxmusl-arm64': 1.0.69 + '@github/copilot-linuxmusl-x64': 1.0.69 + '@github/copilot-win32-arm64': 1.0.69 + '@github/copilot-win32-x64': 1.0.69 '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: From 7f593638c67249ff09e7da57ccafcfc05d0e0037 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 17:59:42 +0200 Subject: [PATCH 142/155] fix(copilot): address review follow-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 87 ++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 22 ++- .../src/provider/copilotRuntime.test.ts | 8 +- apps/server/src/provider/copilotRuntime.ts | 16 +- apps/web/src/components/chat/ChatComposer.tsx | 162 +++++++++--------- apps/web/src/providerInstances.test.ts | 45 +++++ apps/web/src/providerInstances.ts | 34 ++++ 7 files changed, 266 insertions(+), 108 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5e68a734922e..5e3280d87f87 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -495,7 +495,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { serverUrl: "http://127.0.0.1:43123/mcp/", reason: "initial", }, - { sessionId: threadId }, + { sessionId: runtimeMock.state.lastSession.sessionId }, ), ), ); @@ -1364,6 +1364,14 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { // The second turn's SDK user event can arrive after that turn completed // and after a new app turn was queued. It must bind to the oldest local // turn missing a history boundary instead of hijacking the new queue. + config.onEvent?.({ + id: "subagent-user-message", + timestamp, + parentId: null, + type: "user.message", + agentId: "subagent-history", + data: { content: "subagent prompt" }, + } as SessionEvent); config.onEvent?.({ id: "user-message-second", timestamp, @@ -6188,7 +6196,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("completes the active turn on session.idle", () => + it.effect("waits for idle before completing a successful tool turn with a queued follow-up", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-session-idle-completion"); @@ -6236,6 +6244,49 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-transient-idle", }, } as SessionEvent); + emit({ + id: "evt-copilot-successful-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-successful-idle", + toolName: "shell", + turnId: "sdk-turn-transient-idle", + arguments: { + command: "true", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-successful-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-successful-idle", + turnId: "sdk-turn-transient-idle", + success: true, + result: { + content: "Command completed", + }, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + const queuedTurn = yield* adapter.sendTurn({ + threadId, + input: "queue this until the successful tool turn is idle", + attachments: [], + }); + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ), + false, + ); + emit({ id: "evt-copilot-session-idle", timestamp, @@ -6245,6 +6296,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* TestClock.adjust("300 millis"); for ( let attempt = 0; @@ -6258,16 +6311,17 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(queuedTurn.turnId), + ), + false, ); - NodeAssert.ok(idleStateChanged); emit({ id: "evt-copilot-session-idle-duplicate", @@ -6278,21 +6332,20 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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(); - } + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); const completionsAfterDuplicateIdle = runtimeEvents.filter( (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); NodeAssert.equal(completionsAfterDuplicateIdle.length, 1); + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(queuedTurn.turnId), + ), + false, + ); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 92244fee225c..9603b83d965f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -223,6 +223,7 @@ interface CopilotSessionContext { readonly copilotTasks: Map; readonly turnIdsWithAssistantText: Set; readonly turnIdsWithRootAssistantTextSinceToolStart: Set; + readonly turnIdsWithSuccessfulToolCompletion: Set; readonly startedItemIds: Set; readonly completedAssistantItemIds: Set; readonly turnEndEventsByTurnId: Map; @@ -1218,6 +1219,9 @@ function bindSdkUserMessageToTurn( context: CopilotSessionContext, event: SessionUserMessageEvent, ): TurnId | undefined { + if (event.agentId !== undefined) { + return undefined; + } const turnId = resolveTurnIdForSdkUserMessage(context, event); if (!turnId) { return undefined; @@ -1567,6 +1571,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( cancelTurnEndFallback(context, turnId); context.turnEndEventsByTurnId.delete(turnId); context.turnIdsWithRootAssistantTextSinceToolStart.delete(turnId); + context.turnIdsWithSuccessfulToolCompletion.delete(turnId); }; const completeAssistantTextItem = async (input: { @@ -1645,6 +1650,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.pendingTaskCompletionTextByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); context.turnIdsWithRootAssistantTextSinceToolStart.delete(turnId); + context.turnIdsWithSuccessfulToolCompletion.delete(turnId); context.turnStartPayloadByTurnId.delete(turnId); clearSdkTurnMappingsForTurn(context, turnId); if (context.activeTurnId === turnId) { @@ -1685,6 +1691,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const hasTurnCompletionSignal = (context: CopilotSessionContext, turnId: TurnId): boolean => context.turnEndEventsByTurnId.has(turnId) || context.turnIdsWithRootAssistantTextSinceToolStart.has(turnId) || + context.turnIdsWithSuccessfulToolCompletion.has(turnId) || context.pendingTaskCompletionTextByTurnId.has(turnId); const shouldCompleteOnAssistantTurnEnd = ( @@ -1729,7 +1736,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( Effect.promise(async () => { context.turnEndFallbackTimers.delete(turnId); context.eventChain = context.eventChain.then(async () => { - if (!shouldCompleteOnAssistantTurnEnd(context, turnId) || context.stopped) { + if (!shouldCompleteOnIdleSignal(context, turnId) || context.stopped) { return; } await emitPendingTaskCompletionAsAssistantMessage(context, turnId, raw); @@ -2211,10 +2218,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const makeMcpAuthHandler = ( threadId: ThreadId, - expectedSdkSessionId: string, + expectedSdkSessionId?: string, ): NonNullable => { return (request, handlerContext) => { - if (handlerContext.sessionId !== expectedSdkSessionId) { + if (expectedSdkSessionId !== undefined && handlerContext.sessionId !== expectedSdkSessionId) { return CANCELLED_MCP_AUTH_RESULT; } const mcpSession = McpProviderSession.readMcpProviderSession(threadId); @@ -2857,8 +2864,13 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( trimOrUndefined(event.data.result?.content) ?? trimOrUndefined(event.data.error?.message); const detail = normalizedToolCompletionDetail(toolMeta, rawDetail); + const continuesWithSubagent = + toolMeta?.itemType === "collab_agent_tool_call" && !isTaskCompleteTool(toolMeta.toolName); if (event.agentId === undefined && !event.data.success) { context.turnEndEventsByTurnId.set(turnId, event); + } else if (event.agentId === undefined && !continuesWithSubagent) { + context.turnIdsWithSuccessfulToolCompletion.add(turnId); + scheduleTurnEndFallback(context, turnId, event); } if (isTaskCompleteTool(toolMeta?.toolName)) { if (event.agentId === undefined && event.data.success && detail) { @@ -3253,7 +3265,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( ...baseSessionConfig, sessionId: input.threadId, onPermissionRequest: onSessionPermissionRequest, - onMcpAuthRequest: makeMcpAuthHandler(input.threadId, input.threadId), + onMcpAuthRequest: makeMcpAuthHandler(input.threadId), onUserInputRequest: onSessionUserInputRequest, }); @@ -3329,6 +3341,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( copilotTasks: new Map(), turnIdsWithAssistantText: new Set(), turnIdsWithRootAssistantTextSinceToolStart: new Set(), + turnIdsWithSuccessfulToolCompletion: new Set(), startedItemIds: new Set(), completedAssistantItemIds: new Set(), turnEndEventsByTurnId: new Map(), @@ -3823,6 +3836,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.pendingTaskCompletionTextByTurnId.delete(turnId); context.emittedTurnDiffByTurnId.delete(turnId); context.turnIdsWithAssistantText.delete(turnId); + context.turnIdsWithSuccessfulToolCompletion.delete(turnId); context.turnEndEventsByTurnId.delete(turnId); clearSdkTurnMappingsForTurn(context, turnId); } diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 52109d11f6d1..95ee1c45ce87 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -124,10 +124,10 @@ describe("buildCopilotClientOptions", () => { NodeAssert.equal(env.PATH, "/custom/bin:/bin"); }); - it.effect("returns typed failures for invalid Copilot client configuration", () => + it.effect("returns typed failures for invalid Copilot server URLs", () => Effect.gen(function* () { const exit = yield* Effect.exit( - createCopilotClient({ + buildCopilotClientOptions({ settings: { enabled: true, binaryPath: "", @@ -149,12 +149,12 @@ describe("buildCopilotClientOptions", () => { readonly message?: string; }; NodeAssert.equal(error._tag, "CopilotCliPathResolutionError"); - NodeAssert.equal(error.detail, "Failed to construct Copilot client."); + NodeAssert.equal(error.detail, "The configured Copilot server URL is invalid."); NodeAssert.equal(error.serverUrl, "http://[::1"); NodeAssert.ok(error.cause instanceof Error); NodeAssert.equal( error.message, - "Copilot CLI path resolution failed (serverUrl=http://[::1): Failed to construct Copilot client.", + "Copilot CLI path resolution failed (serverUrl=http://[::1): The configured Copilot server URL is invalid.", ); } }), diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 992c61ae2fca..9f7aa20b3c51 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -475,8 +475,22 @@ export const buildCopilotClientOptions = Effect.fn("buildCopilotClientOptions")( }): Effect.fn.Return { const cliUrl = trimOrUndefined(input.settings.serverUrl); if (cliUrl) { + const connection = yield* Effect.try({ + try: () => { + if (!URL.canParse(cliUrl)) { + throw new TypeError("Invalid Copilot server URL."); + } + return RuntimeConnection.forUri(cliUrl); + }, + catch: (cause) => + copilotClientConfigurationError({ + settings: input.settings, + detail: "The configured Copilot server URL is invalid.", + cause, + }), + }); return { - connection: RuntimeConnection.forUri(cliUrl), + connection, ...(input.logLevel ? { logLevel: input.logLevel } : {}), ...(input.onListModels ? { onListModels: input.onListModels } : {}), }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 58136f3102b0..4054720e6f71 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -110,7 +110,7 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - resolveSelectableProviderInstance, + resolveComposerProviderInstance, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, @@ -433,7 +433,7 @@ export interface ChatComposerHandle { selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; - }; + } | null; } // -------------------------------------------------------------------------- @@ -694,7 +694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, explicitSelectedInstanceId, ) ?? ProviderDriverKind.make("codex"); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const requestedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -718,55 +718,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ignore picker selections). // 2. Thread's persisted instance id (server-side saved selection). // 3. Project default's instance id. - // 4. First enabled entry matching the current driver kind. - // 5. First enabled entry overall / default instance for the kind. + // 4. First enabled and available entry matching the current driver kind. + // 5. First enabled and available entry allowed by the thread lock. // - const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - const byKind = providerInstanceEntries.find( - (entry) => - entry.enabled && - entry.isAvailable && - entry.driverKind === selectedProvider && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - if (byKind) return byKind.instanceId; - const selectableEntries = providerInstanceEntries.filter( - (entry) => - (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - const selectableInstanceId = resolveSelectableProviderInstance(selectableEntries, undefined); - return ( - selectableInstanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") - ); + const selectedInstanceId = useMemo(() => { + return resolveComposerProviderInstance({ + entries: providerInstanceEntries, + candidates: [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + selectedProvider: requestedProvider, + lockedProvider, + lockedContinuationGroupKey, + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, @@ -775,8 +742,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, - selectedProvider, + requestedProvider, ]); + const providerSelectionUnavailable = selectedInstanceId === undefined; + const selectedProviderEntry = useMemo( + () => + selectedInstanceId + ? providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId) + : undefined, + [providerInstanceEntries, selectedInstanceId], + ); + const selectedProvider = selectedProviderEntry?.driverKind ?? requestedProvider; + const selectedInstanceIdForPicker = + selectedInstanceId ?? ProviderInstanceId.make(selectedProvider); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -791,10 +769,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Resolve the active instance's snapshot by `instanceId` so a custom // instance gets its own slash commands, skills, and model list — not // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], - ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -815,7 +789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model: selectedModel, models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, }), [ composerModelOptions, @@ -838,8 +812,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [providerStatuses, selectedProvider], ); - const selectedModelSelection = useMemo( - () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), + const selectedModelSelection = useMemo( + () => + selectedInstanceId + ? createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch) + : null, [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], ); const selectedModelForPicker = selectedModel; @@ -857,7 +834,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return out; }, [providerInstanceEntries, settings]); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; + const currentOptions = selectedInstanceId + ? (modelOptionsByInstance.get(selectedInstanceId) ?? []) + : []; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); @@ -1120,23 +1099,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - instanceId: selectedInstanceId, + ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}), ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, prompt, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - instanceId: selectedInstanceId, + ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}), ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], + modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, prompt, onPromptChange: setPromptFromTraits, }); @@ -1158,6 +1137,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isConnecting || projectSelectionRequired || + providerSelectionUnavailable || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1696,7 +1676,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || phase === "running") return false; + if (isSendBusy || isConnecting || providerSelectionUnavailable || phase === "running") { + return false; + } if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1709,17 +1691,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isMobileViewport, isSendBusy, phase, + providerSelectionUnavailable, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (providerSelectionUnavailable) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [ + blurMobileComposerAfterSend, + onSend, + providerSelectionUnavailable, + shouldBlurMobileComposerOnSubmit, + ], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2027,20 +2019,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, - getSendContext: () => ({ - prompt: promptRef.current, - images: composerImagesRef.current, - terminalContexts: composerTerminalContextsRef.current, - elementContexts: composerElementContextsRef.current, - previewAnnotations: composerPreviewAnnotations, - reviewComments: composerReviewComments, - selectedPromptEffort, - selectedModelOptionsForDispatch, - selectedModelSelection, - selectedProvider, - selectedModel, - selectedProviderModels, - }), + getSendContext: () => + selectedModelSelection + ? { + prompt: promptRef.current, + images: composerImagesRef.current, + terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, + previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedProvider, + selectedModel, + selectedProviderModels, + } + : null, }), [ activeThread, @@ -2513,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

{ }); }); +describe("resolveComposerProviderInstance", () => { + it("falls back from an unavailable draft selection to an available provider", () => { + const unavailable = ProviderInstanceId.make("copilot"); + const fallback = ProviderInstanceId.make("codex"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("copilot"), + instanceId: unavailable, + availability: "unavailable", + }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: fallback }), + ]); + + expect( + resolveComposerProviderInstance({ + entries, + candidates: [unavailable], + selectedProvider: ProviderDriverKind.make("copilot"), + }), + ).toBe(fallback); + }); + + it("returns no selection instead of routing a locked thread through an unavailable instance", () => { + const unavailable = ProviderInstanceId.make("copilot"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("copilot"), + instanceId: unavailable, + availability: "unavailable", + }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + + expect( + resolveComposerProviderInstance({ + entries, + candidates: [unavailable], + selectedProvider: ProviderDriverKind.make("copilot"), + lockedProvider: ProviderDriverKind.make("copilot"), + }), + ).toBeUndefined(); + }); +}); + describe("resolveProviderDriverKindForInstanceSelection", () => { it("maps custom provider instance ids back to their driver kind", () => { const providers = [ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 63d61b6076af..43cbebe90dd4 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -273,6 +273,40 @@ export function resolveSelectableProviderInstance( return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; } +/** Resolve the composer target without crossing a thread's provider or continuation lock. */ +export function resolveComposerProviderInstance(input: { + entries: ReadonlyArray; + candidates: ReadonlyArray; + selectedProvider: ProviderDriverKind; + lockedProvider?: ProviderDriverKind | null | undefined; + lockedContinuationGroupKey?: string | null | undefined; +}): ProviderInstanceId | undefined { + const isSelectable = (entry: ProviderInstanceEntry): boolean => + entry.enabled && + entry.isAvailable && + (!input.lockedProvider || entry.driverKind === input.lockedProvider) && + (!input.lockedContinuationGroupKey || + entry.continuationGroupKey === input.lockedContinuationGroupKey); + + for (const candidate of input.candidates) { + if (!candidate) { + continue; + } + const match = input.entries.find( + (entry) => entry.instanceId === candidate && isSelectable(entry), + ); + if (match) { + return match.instanceId; + } + } + + return ( + input.entries.find( + (entry) => isSelectable(entry) && entry.driverKind === input.selectedProvider, + )?.instanceId ?? input.entries.find(isSelectable)?.instanceId + ); +} + /** * Resolve an open model-selection routing key back to a driver kind. * Custom instance ids such as `claude_openrouter` are not themselves From b6deabf84b0f106fa5fbf841549ee7cefc347e7e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 18:25:51 +0200 Subject: [PATCH 143/155] fix(copilot): debounce successful tool completion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 149 ++++++++++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 9 +- 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 5e3280d87f87..7d57c84f9197 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -6351,6 +6351,155 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("does not complete a successful tool turn when the assistant loop continues", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-successful-tool-continuation"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "continue after a successful tool", + 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-tool-loop-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-tool-loop", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-tool-loop-tool-start", + timestamp, + parentId: null, + type: "tool.execution_start", + data: { + toolCallId: "tool-loop-success", + toolName: "shell", + turnId: "sdk-turn-tool-loop", + arguments: { + command: "true", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-tool-loop-tool-complete", + timestamp, + parentId: null, + type: "tool.execution_complete", + data: { + toolCallId: "tool-loop-success", + turnId: "sdk-turn-tool-loop", + success: true, + result: { + content: "Command completed", + }, + }, + } as SessionEvent); + emit({ + id: "evt-copilot-tool-loop-transient-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + emit({ + id: "evt-copilot-tool-loop-continuation-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-tool-loop-continuation", + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* TestClock.adjust("300 millis"); + + emit({ + id: "evt-copilot-tool-loop-stale-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ), + false, + ); + + emit({ + id: "evt-copilot-tool-loop-continuation-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-tool-loop-continuation", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-tool-loop-final-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); + + 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; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 9603b83d965f..9571597fdb7b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2458,7 +2458,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } if (shouldCompleteOnIdleSignal(context, turnId)) { - if (context.turnEndFallbackTimers.has(turnId)) { + if ( + context.turnEndFallbackTimers.has(turnId) || + context.turnIdsWithSuccessfulToolCompletion.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); @@ -2576,8 +2579,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "assistant.turn_start": { await completePendingActiveTurnEnd(context); - if (context.activeTurnId) { + if (event.agentId === undefined && context.activeTurnId) { cancelTurnEndFallback(context, context.activeTurnId); + context.turnIdsWithSuccessfulToolCompletion.delete(context.activeTurnId); } const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { timestamp: event.timestamp, @@ -2870,7 +2874,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( context.turnEndEventsByTurnId.set(turnId, event); } else if (event.agentId === undefined && !continuesWithSubagent) { context.turnIdsWithSuccessfulToolCompletion.add(turnId); - scheduleTurnEndFallback(context, turnId, event); } if (isTaskCompleteTool(toolMeta?.toolName)) { if (event.agentId === undefined && event.data.success && detail) { From b1b11e89fd01ac19d58f853e87faeb3affeccc12 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 14 Jul 2026 18:35:59 +0200 Subject: [PATCH 144/155] fix(copilot): preserve rejected turn completion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/provider/Layers/CopilotAdapter.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 9571597fdb7b..629253d85e1e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -2579,10 +2579,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( } case "assistant.turn_start": { await completePendingActiveTurnEnd(context); - if (event.agentId === undefined && context.activeTurnId) { - cancelTurnEndFallback(context, context.activeTurnId); - context.turnIdsWithSuccessfulToolCompletion.delete(context.activeTurnId); - } + const activeTurnIdBeforeSdkTurn = context.activeTurnId; const turnId = resolveTurnIdForSdkTurn(context, event.data.turnId, { timestamp: event.timestamp, agentId: event.agentId, @@ -2590,6 +2587,10 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( if (!turnId) { return; } + if (event.agentId === undefined && turnId === activeTurnIdBeforeSdkTurn) { + cancelTurnEndFallback(context, turnId); + context.turnIdsWithSuccessfulToolCompletion.delete(turnId); + } updateProviderSession(context, { status: "running", activeTurnId: turnId, From 17d1aa0171c094edfc2f5862b991b52aff03ba8b Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 11:17:00 +0200 Subject: [PATCH 145/155] feat(copilot): generate summarized thread titles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layers/ProviderCommandReactor.test.ts | 9 +++-- .../Layers/ProviderCommandReactor.ts | 19 ----------- .../CopilotTextGeneration.test.ts | 32 ++++++++++++----- .../textGeneration/CopilotTextGeneration.ts | 34 +++++++++++-------- 4 files changed, 50 insertions(+), 44 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b0f040c7e0db..5625fbe4a2de 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -640,7 +640,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe(seededTitle); }); - it("keeps the client-seeded title while starting the first Copilot turn", async () => { + it("generates a summarized title while starting the first Copilot turn", async () => { const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); const harness = await createHarness({ threadModelSelection: copilotSelection, @@ -648,6 +648,9 @@ describe("ProviderCommandReactor", () => { }); const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Investigate Copilot thread startup"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Copilot startup errors" }), + ); await runtime!.runPromise( harness.engine.dispatch({ @@ -679,10 +682,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("Copilot startup errors"); }); it("replaces the default Copilot title with the local fallback", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9f3c41c6fd66..3fdda63a88ff 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_DRIVER_KIND = ProviderDriverKind.make("copilot"); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -744,24 +743,6 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - const textGenerationProvider = yield* providerService.getInstanceInfo( - modelSelection.instanceId, - ); - if (textGenerationProvider.driverKind === COPILOT_DRIVER_KIND) { - const thread = yield* resolveThread(input.threadId); - if (!thread) return; - - const currentTitle = thread.title.trim(); - const titleSeed = input.titleSeed?.trim(); - const hasClientSeededTitle = - currentTitle !== DEFAULT_THREAD_TITLE && - titleSeed !== undefined && - titleSeed.length > 0 && - currentTitle === titleSeed; - if (hasClientSeededTitle) { - return; - } - } const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts index 8e01c7b0dba4..0d1b3c9839c7 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.test.ts @@ -30,6 +30,10 @@ const runtimeMock = vi.hoisted(() => { clientStartGate: null as Promise | null, clientStartError: null as Error | null, stopErrors: [] as Error[], + responseContent: { + subject: "Add change", + body: "", + } as Record, }; return { @@ -41,6 +45,10 @@ const runtimeMock = vi.hoisted(() => { state.clientStartGate = null; state.clientStartError = null; state.stopErrors = []; + state.responseContent = { + subject: "Add change", + body: "", + }; }, }; }); @@ -66,10 +74,7 @@ vi.mock("../provider/copilotRuntime.ts", async () => { runtimeMock.state.sessionConfigs.push(config); const sendAndWait = vi.fn(async () => ({ data: { - content: JSON.stringify({ - subject: "Add change", - body: "", - }), + content: JSON.stringify(runtimeMock.state.responseContent), }, })); const disconnect = vi.fn(async () => undefined); @@ -325,10 +330,13 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { }), ); - it.effect("generates local thread title fallbacks without starting a Copilot SDK session", () => + it.effect("generates summarized thread titles through Copilot", () => Effect.gen(function* () { const textGeneration = yield* makeCopilotTextGeneration(defaultCopilotSettings); const modelSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); + runtimeMock.state.responseContent = { + title: "Fix reconnect startup", + }; const result = yield* textGeneration.generateThreadTitle({ cwd: process.cwd(), @@ -336,9 +344,17 @@ it.layer(CopilotTextGenerationTestLayer)("CopilotTextGeneration", (it) => { modelSelection, }); - expect(result.title).toBe("Investigate Copilot thread startup errors after..."); - expect(runtimeMock.state.createdClients).toHaveLength(0); - expect(runtimeMock.state.sessions).toHaveLength(0); + expect(result.title).toBe("Fix reconnect startup"); + expect(runtimeMock.state.createdClients).toHaveLength(1); + expect(runtimeMock.state.sessions).toHaveLength(1); + expect(runtimeMock.state.sessions[0]?.sendAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining( + "Title should summarize the user's request, not restate it verbatim.", + ), + }), + 180_000, + ); }), ); }); diff --git a/apps/server/src/textGeneration/CopilotTextGeneration.ts b/apps/server/src/textGeneration/CopilotTextGeneration.ts index 13902513d353..d63a0da0ce4f 100644 --- a/apps/server/src/textGeneration/CopilotTextGeneration.ts +++ b/apps/server/src/textGeneration/CopilotTextGeneration.ts @@ -30,6 +30,7 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { @@ -46,7 +47,8 @@ const COPILOT_TEXT_GENERATION_IDLE_TTL = "30 seconds"; type CopilotTextGenerationOperation = | "generateCommitMessage" | "generatePrContent" - | "generateBranchName"; + | "generateBranchName" + | "generateThreadTitle"; type CopilotReasoningEffort = NonNullable; type CopilotContextTier = NonNullable; @@ -106,16 +108,6 @@ 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, @@ -529,9 +521,23 @@ export const makeCopilotTextGeneration = Effect.fn("makeCopilotTextGeneration")( ); }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( - input, - ) => Effect.succeed(copilotThreadTitleFallback(input)); + 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); + }); return { generateCommitMessage, From a5a227c89fda5a21415763a7c2cbfe1902083d3a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 11:17:13 +0200 Subject: [PATCH 146/155] feat(plans): render status-aware task lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/Layers/CopilotAdapter.test.ts | 69 +++++------ .../src/provider/Layers/CopilotAdapter.ts | 116 ------------------ apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/PlanSidebar.tsx | 68 ++-------- apps/web/src/components/PlanTaskList.tsx | 82 +++++++++++++ .../src/components/chat/ProposedPlanCard.tsx | 63 ++++++---- apps/web/src/proposedPlan.test.ts | 43 +++++++ apps/web/src/proposedPlan.ts | 98 +++++++++++++++ apps/web/src/session-logic.test.ts | 21 ++++ apps/web/src/session-logic.ts | 4 + 10 files changed, 337 insertions(+), 236 deletions(-) create mode 100644 apps/web/src/components/PlanTaskList.tsx diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 7d57c84f9197..78542438e100 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -4331,7 +4331,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("emits Copilot background tasks as task list plan updates", () => + it.effect("emits Copilot background task lifecycle events without plan updates", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-background-tasks-plan"); @@ -4343,7 +4343,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ + yield* adapter.sendTurn({ threadId, input: "delegate the investigation", attachments: [], @@ -4402,26 +4402,19 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { data: {}, } as SessionEvent); - let planEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && planEvent === undefined; attempt += 1) { + for ( + let attempt = 0; + attempt < 20 && runtimeEvents.filter((event) => event.type === "task.started").length < 3; + attempt += 1 + ) { yield* waitForSdkEventQueue(); - planEvent = runtimeEvents.find((event) => event.type === "turn.plan.updated"); } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - NodeAssert.equal(planEvent?.type, "turn.plan.updated"); - if (planEvent?.type === "turn.plan.updated") { - NodeAssert.equal(planEvent.threadId, threadId); - NodeAssert.equal(String(planEvent.turnId), String(turn.turnId)); - NodeAssert.deepStrictEqual(planEvent.payload, { - explanation: "Copilot Tasks", - plan: [ - { step: "Exploring provider events", status: "inProgress" }, - { step: "Running tests", status: "completed" }, - { step: "Reviewing implementation (failed)", status: "pending" }, - ], - }); - } + NodeAssert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); NodeAssert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ "task-explore-1", @@ -4572,7 +4565,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("maps Copilot todo tool input to T3 plan updates", () => + it.effect("does not map Copilot todo tool input to plan updates", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; const threadId = asThreadId("copilot-todo-tool-plan-update"); @@ -4584,7 +4577,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ + yield* adapter.sendTurn({ threadId, input: "track todos", attachments: [], @@ -4627,26 +4620,32 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { turnId: "sdk-turn-todo-tool", }, } as SessionEvent); + config.onEvent({ + id: "evt-copilot-todo-drain-marker", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { + turnId: "sdk-turn-after-todo-tool", + }, + } as SessionEvent); - let planEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && planEvent === undefined; attempt += 1) { + let markerEvent: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { yield* waitForSdkEventQueue(); - planEvent = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + markerEvent = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + event.payload.reason === "Copilot turn started", + ); } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - NodeAssert.equal(planEvent?.type, "turn.plan.updated"); - if (planEvent?.type === "turn.plan.updated") { - NodeAssert.equal(String(planEvent.turnId), String(turn.turnId)); - NodeAssert.deepStrictEqual(planEvent.payload, { - explanation: "Copilot Todos", - plan: [ - { step: "Inspect adapter", status: "completed" }, - { step: "Wire task events", status: "inProgress" }, - { step: "Run validation", status: "pending" }, - ], - }); - } + NodeAssert.equal(markerEvent?.type, "session.state.changed"); + NodeAssert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 629253d85e1e..8274d70e05b5 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -98,11 +98,6 @@ type CopilotTaskList = Awaited 0 ? diffCandidate : undefined; } -function normalizeCopilotTaskStatus(status: CopilotTaskStatus): PlanStep["status"] { - switch (status) { - case "completed": - return "completed"; - case "running": - case "idle": - return "inProgress"; - case "failed": - case "cancelled": - return "pending"; - } -} - function completedCopilotTaskStatus( status: CopilotTaskStatus, ): "completed" | "failed" | "stopped" | undefined { @@ -797,70 +779,6 @@ function copilotTaskCompletionSummary(task: CopilotTaskInfo): string | undefined return task.error ?? task.result ?? task.latestResponse ?? task.description; } -function copilotTaskStatusSuffix(status: CopilotTaskStatus): string { - switch (status) { - case "failed": - return " (failed)"; - case "cancelled": - return " (cancelled)"; - case "running": - case "idle": - case "completed": - return ""; - } -} - -function planStepsFromCopilotTasks(tasks: ReadonlyArray): PlanStep[] { - return tasks.map((task) => { - const description = copilotTaskDescription(task); - return { - step: `${description}${copilotTaskStatusSuffix(task.status)}`, - status: normalizeCopilotTaskStatus(task.status), - }; - }); -} - -function isTodoTool(toolName: string): boolean { - const normalized = toolName.toLowerCase().replace(/[^a-z0-9]+/g, ""); - return normalized.includes("todo"); -} - -function normalizeTodoStatus(value: unknown): PlanStep["status"] { - const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (normalized === "completed" || normalized === "done") { - return "completed"; - } - if ( - normalized === "in_progress" || - normalized === "inprogress" || - normalized === "running" || - normalized === "active" - ) { - return "inProgress"; - } - return "pending"; -} - -function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | undefined { - const todos = input.todos; - if (!Array.isArray(todos) || todos.length === 0) { - return undefined; - } - const steps = todos.flatMap((todo): Array => { - if (!isStringRecord(todo)) { - return []; - } - const step = readString(todo.content) ?? readString(todo.title) ?? readString(todo.task); - return [ - { - step: step ?? "Task", - status: normalizeTodoStatus(todo.status), - }, - ]; - }); - return steps.length > 0 ? steps : undefined; -} - function isStringRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -2142,22 +2060,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( status: task.status, }); } - const plan = planStepsFromCopilotTasks(taskList.tasks); - if (plan.length === 0) { - return; - } - yield* emit({ - ...createBaseEvent({ - threadId: context.threadId, - turnId, - raw, - }), - type: "turn.plan.updated", - payload: { - explanation: "Copilot Tasks", - plan, - }, - }); }); const onPermissionRequest = ( @@ -2750,24 +2652,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } markTurnContinuingWithTool(context, turnId); - 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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 16cd938cfd0a..837f96558c88 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1926,8 +1926,13 @@ function ChatViewContent(props: ChatViewProps) { [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + () => + deriveActivePlanState( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + selectedProvider, + ), + [activeLatestTurn?.turnId, selectedProvider, threadActivities], ); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index abc0db79b6cb..da3f3b690602 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -9,13 +9,7 @@ import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; import ChatMarkdown from "./ChatMarkdown"; -import { - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - EllipsisIcon, - LoaderIcon, -} from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import type { ActivePlanState } from "../session-logic"; import type { LatestProposedPlanState } from "../session-logic"; @@ -25,36 +19,16 @@ import { buildProposedPlanMarkdownFilename, normalizePlanMarkdownForExport, downloadPlanAsTextFile, + extractProposedPlanTasks, stripDisplayedPlanMarkdown, } from "../proposedPlan"; +import { PlanTaskList } from "./PlanTaskList"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { projectEnvironment } from "~/state/projects"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useAtomCommand } from "~/state/use-atom-command"; -function stepStatusIcon(status: string): React.ReactNode { - if (status === "completed") { - return ( - - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; @@ -88,6 +62,8 @@ const PlanSidebar = memo(function PlanSidebar({ const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; + const planTasks = + activePlan?.steps ?? (planMarkdown ? extractProposedPlanTasks(planMarkdown) : []); const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; @@ -203,37 +179,7 @@ const PlanSidebar = memo(function PlanSidebar({ ) : null} {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} + {planTasks.length > 0 ? : null} {/* Proposed Plan Markdown */} {planMarkdown ? ( @@ -249,7 +195,7 @@ const PlanSidebar = memo(function PlanSidebar({ )} - {planTitle ?? "Full Plan"} + {planTasks.length > 0 ? "Details" : (planTitle ?? "Full Plan")} {proposedPlanExpanded ? ( diff --git a/apps/web/src/components/PlanTaskList.tsx b/apps/web/src/components/PlanTaskList.tsx new file mode 100644 index 000000000000..0c2236b643de --- /dev/null +++ b/apps/web/src/components/PlanTaskList.tsx @@ -0,0 +1,82 @@ +import { CheckIcon, LoaderIcon } from "lucide-react"; + +import type { ProposedPlanTask } from "../proposedPlan"; +import { cn } from "~/lib/utils"; + +function PlanTaskStatusIcon({ status }: { status: ProposedPlanTask["status"] }) { + if (status === "completed") { + return ( + + + ); + } + if (status === "inProgress") { + return ( + + + ); + } + return ( + + + ); +} + +export function PlanTaskList({ + tasks, + label = "Tasks", +}: { + tasks: ReadonlyArray; + label?: string; +}) { + const completedCount = tasks.filter((task) => task.status === "completed").length; + + return ( +
+
+

+ {label} +

+ + {completedCount}/{tasks.length} + +
+ {tasks.map((task, index) => ( +
+ +

+ {task.step} +

+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 9746857a8cac..7028353aa1ff 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -8,6 +8,7 @@ import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, downloadPlanAsTextFile, + extractProposedPlanTasks, normalizePlanMarkdownForExport, proposedPlanTitle, stripDisplayedPlanMarkdown, @@ -32,6 +33,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { projectEnvironment } from "~/state/projects"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useAtomCommand } from "~/state/use-atom-command"; +import { PlanTaskList } from "../PlanTaskList"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, @@ -70,6 +72,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const lineCount = planMarkdown.split("\n").length; const canCollapse = planMarkdown.length > 900 || lineCount > 20; const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const planTasks = extractProposedPlanTasks(planMarkdown); const collapsedPreview = canCollapse ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) : null; @@ -170,27 +173,37 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
-
- {canCollapse && !expanded ? ( - - ) : ( - - )} - {canCollapse && !expanded ? ( -
- ) : null} -
- {canCollapse ? ( + {planTasks.length > 0 ? : null} + {planTasks.length === 0 || expanded ? ( +
0 && + "mt-4 rounded-lg border border-border/50 bg-background/50 p-3", + planTasks.length === 0 && canCollapse && !expanded && "max-h-104 overflow-hidden", + )} + > + {planTasks.length === 0 && canCollapse && !expanded ? ( + + ) : ( + + )} + {planTasks.length === 0 && canCollapse && !expanded ? ( +
+ ) : null} +
+ ) : null} + {canCollapse || planTasks.length > 0 ? (
) : null} diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index 99732b5761c9..b9ebc81f1c50 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -5,6 +5,7 @@ import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, + extractProposedPlanTasks, proposedPlanTitle, resolvePlanFollowUpSubmission, stripDisplayedPlanMarkdown, @@ -20,6 +21,48 @@ describe("proposedPlanTitle", () => { }); }); +describe("extractProposedPlanTasks", () => { + it("prefers a named task section over descriptive lists", () => { + expect( + extractProposedPlanTasks( + [ + "# Integrate RPC", + "## Summary", + "- Keep existing behavior", + "- Avoid extra round trips", + "## Tasks", + "1. Add the shared contract", + "2. Wire the server", + "3. Render the client state", + ].join("\n"), + ), + ).toEqual([ + { step: "Add the shared contract", status: "pending" }, + { step: "Wire the server", status: "pending" }, + { step: "Render the client state", status: "pending" }, + ]); + }); + + it("preserves statuses from markdown task markers", () => { + expect(extractProposedPlanTasks("## Work\n- [x] Parse tasks\n- [ ] Render statuses")).toEqual([ + { step: "Parse tasks", status: "completed" }, + { step: "Render statuses", status: "pending" }, + ]); + }); + + it("ignores lists inside fenced code blocks", () => { + expect( + extractProposedPlanTasks( + ["```md", "- [ ] Not a real task", "```", "", "1. Implement the parser"].join("\n"), + ), + ).toEqual([{ step: "Implement the parser", status: "pending" }]); + }); + + it("returns no tasks when the plan has no list", () => { + expect(extractProposedPlanTasks("# Plan\n\nImplement the feature safely.")).toEqual([]); + }); +}); + describe("buildPlanImplementationPrompt", () => { it("formats the plan exactly like the Codex follow-up handoff prompt", () => { expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 48186392e8a3..2e77f11f4fcb 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -3,6 +3,104 @@ export function proposedPlanTitle(planMarkdown: string): string | null { return heading && heading.length > 0 ? heading : null; } +export interface ProposedPlanTask { + step: string; + status: "pending" | "inProgress" | "completed"; +} + +interface ProposedPlanTaskGroup { + heading: string | null; + explicitTaskCount: number; + tasks: ProposedPlanTask[]; +} + +const TASK_SECTION_HEADING = + /^(?:implementation(?: plan)?|execution(?: plan)?|plan|steps?|tasks?|work plan)$/i; + +function normalizeProposedPlanTaskText(markdown: string): string { + return markdown + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_~`]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask[] { + const groups: ProposedPlanTaskGroup[] = []; + let heading: string | null = null; + let currentGroup: ProposedPlanTaskGroup | null = null; + let fenced = false; + + const finishGroup = () => { + if (currentGroup?.tasks.length) { + groups.push(currentGroup); + } + currentGroup = null; + }; + + for (const line of planMarkdown.split(/\r?\n/)) { + if (/^\s{0,3}(?:```|~~~)/.test(line)) { + fenced = !fenced; + finishGroup(); + continue; + } + if (fenced) { + continue; + } + + const headingMatch = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); + if (headingMatch) { + finishGroup(); + heading = normalizeProposedPlanTaskText(headingMatch[1] ?? "") || null; + continue; + } + + const itemMatch = line.match(/^\s{0,3}(?:[-+*]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.+?)\s*$/); + if (itemMatch) { + const step = normalizeProposedPlanTaskText(itemMatch[2] ?? ""); + if (!step) { + continue; + } + currentGroup ??= { + heading, + explicitTaskCount: 0, + tasks: [], + }; + const marker = itemMatch[1]; + if (marker !== undefined) { + currentGroup.explicitTaskCount += 1; + } + currentGroup.tasks.push({ + step, + status: marker?.toLowerCase() === "x" ? "completed" : "pending", + }); + continue; + } + + if (line.trim().length > 0 && !/^\s{4,}/.test(line)) { + finishGroup(); + } + } + finishGroup(); + + const rankedGroups = groups.toSorted((left, right) => { + const explicitDifference = right.explicitTaskCount - left.explicitTaskCount; + if (explicitDifference !== 0) { + return explicitDifference; + } + const headingDifference = + Number(TASK_SECTION_HEADING.test(right.heading ?? "")) - + Number(TASK_SECTION_HEADING.test(left.heading ?? "")); + if (headingDifference !== 0) { + return headingDifference; + } + return right.tasks.length - left.tasks.length; + }); + + return rankedGroups[0]?.tasks ?? []; +} + export function stripDisplayedPlanMarkdown(planMarkdown: string): string { const lines = planMarkdown.trimEnd().split(/\r?\n/); const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0dae5df9ba85..58a21adcbd28 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -311,6 +311,27 @@ describe("derivePendingUserInputs", () => { }); describe("deriveActivePlanState", () => { + it("does not expose Copilot task updates as a plan", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "copilot-tasks", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + explanation: "Copilot Tasks", + plan: [{ step: "Inspect implementation", status: "inProgress" }], + }, + }), + ]; + + expect( + deriveActivePlanState(activities, TurnId.make("turn-1"), ProviderDriverKind.make("copilot")), + ).toBeNull(); + }); + it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fc63bddfe762..ada1a84f746c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -511,7 +511,11 @@ export function derivePendingUserInputs( export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, + provider?: ProviderDriverKind, ): ActivePlanState | null { + if (provider === "copilot") { + return null; + } const ordered = [...activities].toSorted(compareActivitiesByOrder); const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); // Prefer plan from the current turn; fall back to the most recent plan from any turn From d2c918d59c7a808615ed7d0b1d0a91d72d817ba4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 11:27:55 +0200 Subject: [PATCH 147/155] fix(plans): prioritize named task sections --- apps/web/src/proposedPlan.test.ts | 23 +++++++++++++++++++++++ apps/web/src/proposedPlan.ts | 8 ++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index b9ebc81f1c50..9159f234fac5 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -43,6 +43,29 @@ describe("extractProposedPlanTasks", () => { ]); }); + it("prefers a named task section over a larger checklist", () => { + expect( + extractProposedPlanTasks( + [ + "# Integrate RPC", + "## Summary", + "- [x] Preserve current behavior", + "- [x] Avoid extra round trips", + "- [ ] Document the migration", + "- [ ] Add release notes", + "## Tasks", + "1. Add the shared contract", + "2. Wire the server", + "3. Render the client state", + ].join("\n"), + ), + ).toEqual([ + { step: "Add the shared contract", status: "pending" }, + { step: "Wire the server", status: "pending" }, + { step: "Render the client state", status: "pending" }, + ]); + }); + it("preserves statuses from markdown task markers", () => { expect(extractProposedPlanTasks("## Work\n- [x] Parse tasks\n- [ ] Render statuses")).toEqual([ { step: "Parse tasks", status: "completed" }, diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 2e77f11f4fcb..688bd72fcb1f 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -85,16 +85,16 @@ export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask finishGroup(); const rankedGroups = groups.toSorted((left, right) => { - const explicitDifference = right.explicitTaskCount - left.explicitTaskCount; - if (explicitDifference !== 0) { - return explicitDifference; - } const headingDifference = Number(TASK_SECTION_HEADING.test(right.heading ?? "")) - Number(TASK_SECTION_HEADING.test(left.heading ?? "")); if (headingDifference !== 0) { return headingDifference; } + const explicitDifference = right.explicitTaskCount - left.explicitTaskCount; + if (explicitDifference !== 0) { + return explicitDifference; + } return right.tasks.length - left.tasks.length; }); From c36061844e2e30d2432a8b3e3d3ec35793909583 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 11:40:21 +0200 Subject: [PATCH 148/155] fix(plans): isolate task list groups --- apps/web/src/proposedPlan.test.ts | 21 +++++++++++++++++++++ apps/web/src/proposedPlan.ts | 1 + 2 files changed, 22 insertions(+) diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index 9159f234fac5..f3ccc2ae40e0 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -66,6 +66,27 @@ describe("extractProposedPlanTasks", () => { ]); }); + it("does not treat a trailing list as part of an earlier task section", () => { + expect( + extractProposedPlanTasks( + [ + "# Integrate RPC", + "## Tasks", + "1. Add the shared contract", + "2. Wire the server", + "3. Render the client state", + "The implementation should preserve current behavior.", + "- [x] Existing behavior documented", + "- [ ] Release notes drafted", + ].join("\n"), + ), + ).toEqual([ + { step: "Add the shared contract", status: "pending" }, + { step: "Wire the server", status: "pending" }, + { step: "Render the client state", status: "pending" }, + ]); + }); + it("preserves statuses from markdown task markers", () => { expect(extractProposedPlanTasks("## Work\n- [x] Parse tasks\n- [ ] Render statuses")).toEqual([ { step: "Parse tasks", status: "completed" }, diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 688bd72fcb1f..6e488729f19d 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -35,6 +35,7 @@ export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask const finishGroup = () => { if (currentGroup?.tasks.length) { groups.push(currentGroup); + heading = null; } currentGroup = null; }; From 5a68ce8c646ee77708335f86190e6dd3de1c57be Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 11:47:42 +0200 Subject: [PATCH 149/155] fix(plans): consolidate task section fragments --- apps/web/src/proposedPlan.test.ts | 22 +++++++++++++++++++++- apps/web/src/proposedPlan.ts | 18 ++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index f3ccc2ae40e0..fbb8e2a80004 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -66,7 +66,7 @@ describe("extractProposedPlanTasks", () => { ]); }); - it("does not treat a trailing list as part of an earlier task section", () => { + it("combines task lists separated by prose", () => { expect( extractProposedPlanTasks( [ @@ -84,6 +84,26 @@ describe("extractProposedPlanTasks", () => { { step: "Add the shared contract", status: "pending" }, { step: "Wire the server", status: "pending" }, { step: "Render the client state", status: "pending" }, + { step: "Existing behavior documented", status: "completed" }, + { step: "Release notes drafted", status: "pending" }, + ]); + }); + + it("combines task lists separated by a fenced code block", () => { + expect( + extractProposedPlanTasks( + [ + "## Tasks", + "1. Add the shared contract", + "```md", + "- [ ] Not a real task", + "```", + "2. Wire the server", + ].join("\n"), + ), + ).toEqual([ + { step: "Add the shared contract", status: "pending" }, + { step: "Wire the server", status: "pending" }, ]); }); diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 6e488729f19d..b046d3c6eba8 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -9,6 +9,7 @@ export interface ProposedPlanTask { } interface ProposedPlanTaskGroup { + sectionId: number; heading: string | null; explicitTaskCount: number; tasks: ProposedPlanTask[]; @@ -29,13 +30,13 @@ function normalizeProposedPlanTaskText(markdown: string): string { export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask[] { const groups: ProposedPlanTaskGroup[] = []; let heading: string | null = null; + let sectionId = 0; let currentGroup: ProposedPlanTaskGroup | null = null; let fenced = false; const finishGroup = () => { if (currentGroup?.tasks.length) { groups.push(currentGroup); - heading = null; } currentGroup = null; }; @@ -53,6 +54,7 @@ export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask const headingMatch = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); if (headingMatch) { finishGroup(); + sectionId += 1; heading = normalizeProposedPlanTaskText(headingMatch[1] ?? "") || null; continue; } @@ -64,6 +66,7 @@ export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask continue; } currentGroup ??= { + sectionId, heading, explicitTaskCount: 0, tasks: [], @@ -85,7 +88,18 @@ export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask } finishGroup(); - const rankedGroups = groups.toSorted((left, right) => { + const consolidatedGroups = groups.reduce((result, group) => { + const previous = result.at(-1); + if (previous?.sectionId === group.sectionId && TASK_SECTION_HEADING.test(group.heading ?? "")) { + previous.explicitTaskCount += group.explicitTaskCount; + previous.tasks.push(...group.tasks); + return result; + } + result.push({ ...group, tasks: [...group.tasks] }); + return result; + }, []); + + const rankedGroups = consolidatedGroups.toSorted((left, right) => { const headingDifference = Number(TASK_SECTION_HEADING.test(right.heading ?? "")) - Number(TASK_SECTION_HEADING.test(left.heading ?? "")); From 9c63d2ac4be8293a246ee2731edc213f7529e31e Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 12:09:53 +0200 Subject: [PATCH 150/155] fix(copilot): preserve custom thread titles --- .../Layers/ProviderCommandReactor.ts | 47 +------------------ .../Layers/ProviderRuntimeIngestion.test.ts | 30 ++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 7 ++- apps/server/src/orchestration/threadTitle.ts | 47 +++++++++++++++++++ 4 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/orchestration/threadTitle.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3fdda63a88ff..8cc27ecf48f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,7 +13,6 @@ 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"; @@ -42,6 +41,7 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { canReplaceThreadTitle } from "../threadTitle.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -87,7 +87,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => 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"; export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -104,50 +103,6 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -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; - } - - const trimmedTitleSeed = titleSeed?.trim(); - if (trimmedTitleSeed === undefined || trimmedTitleSeed.length === 0) { - 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; - } - - return ( - truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed - ); -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 88b5902501d7..199380c9c04d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -227,7 +227,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial }) { + async function createHarness(options?: { + serverSettings?: Partial; + initialThreadTitle?: string; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -289,7 +292,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: options?.initialThreadTitle ?? "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -2728,8 +2731,29 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(true); }); - it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { + it("does not replace a custom thread title from provider metadata", async () => { const harness = await createHarness(); + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-custom-title-metadata-updated"), + provider: ProviderDriverKind.make("copilot"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + }, + }); + + await harness.drain(); + + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === "thread-1"); + expect(thread?.title).toBe("Thread"); + }); + + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { + const harness = await createHarness({ initialThreadTitle: "New thread" }); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 663ef197d925..a26a43a69c0c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -38,6 +38,7 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { canReplaceThreadTitle } from "../threadTitle.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1690,7 +1691,11 @@ const make = Effect.gen(function* () { } } - if (event.type === "thread.metadata.updated" && event.payload.name) { + if ( + event.type === "thread.metadata.updated" && + event.payload.name && + canReplaceThreadTitle(thread.title) + ) { yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: yield* providerCommandId(event, "thread-meta-update"), diff --git a/apps/server/src/orchestration/threadTitle.ts b/apps/server/src/orchestration/threadTitle.ts new file mode 100644 index 000000000000..6533eeea7fdd --- /dev/null +++ b/apps/server/src/orchestration/threadTitle.ts @@ -0,0 +1,47 @@ +import { truncate } from "@t3tools/shared/String"; + +const DEFAULT_THREAD_TITLE = "New thread"; + +function normalizeTitleSeedCandidate(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function canReplaceThreadTitle( + currentTitle: string, + titleSeed?: string, + sourceMessageText?: string, +): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + if (trimmedTitleSeed === undefined || trimmedTitleSeed.length === 0) { + 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; + } + + return ( + truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed + ); +} From eb7b4fbf227db7070e7a6dcd3a07cf5d1b286891 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Wed, 15 Jul 2026 17:46:17 +0200 Subject: [PATCH 151/155] refactor(copilot): narrow provider integration scope --- .../orchestrationEngine.integration.test.ts | 56 - .../Layers/CheckpointReactor.test.ts | 283 +- .../orchestration/Layers/CheckpointReactor.ts | 80 +- .../Layers/ProjectionPipeline.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 485 +- .../Layers/ProviderCommandReactor.ts | 49 +- .../Layers/ProviderRuntimeIngestion.test.ts | 269 +- .../Layers/ProviderRuntimeIngestion.ts | 105 +- apps/server/src/orchestration/threadTitle.ts | 47 - .../provider/Layers/CopilotAdapter.test.ts | 5103 +++-------------- .../src/provider/Layers/CopilotAdapter.ts | 21 - .../src/provider/Layers/CursorAdapter.test.ts | 5 +- .../ProviderInstanceRegistryHydration.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 15 +- apps/server/src/serverSettings.test.ts | 26 - apps/server/src/serverSettings.ts | 63 +- apps/web/src/components/ChatView.tsx | 14 +- apps/web/src/components/PlanSidebar.tsx | 68 +- apps/web/src/components/PlanTaskList.tsx | 82 - apps/web/src/components/chat/ChatComposer.tsx | 159 +- .../components/chat/ModelPickerSidebar.tsx | 1 + .../src/components/chat/ProposedPlanCard.tsx | 63 +- .../settings/ProviderInstanceCard.tsx | 76 +- apps/web/src/proposedPlan.test.ts | 107 - apps/web/src/proposedPlan.ts | 113 - apps/web/src/providerInstances.test.ts | 95 +- apps/web/src/providerInstances.ts | 41 +- apps/web/src/session-logic.test.ts | 82 +- apps/web/src/session-logic.ts | 12 +- .../client-runtime/src/state/threadReducer.ts | 1 - packages/shared/src/DrainableWorker.test.ts | 38 - packages/shared/src/DrainableWorker.ts | 27 +- packages/shared/src/agentAwareness.test.ts | 33 - packages/shared/src/agentAwareness.ts | 3 - 34 files changed, 1008 insertions(+), 6622 deletions(-) delete mode 100644 apps/server/src/orchestration/threadTitle.ts delete mode 100644 apps/web/src/components/PlanTaskList.tsx diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index acd04ecf184a..ccfb9c467421 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -266,62 +266,6 @@ 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/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 817993e3604e..707c87c43c99 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -53,7 +53,6 @@ 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"; @@ -80,12 +79,11 @@ 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( - rollbackConversationImpl ?? (() => Effect.void), + const rollbackConversation = vi.fn( + (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, ); const unsupported =
() => @@ -251,8 +249,7 @@ describe("CheckpointReactor", () => { | OrchestrationEngineService | CheckpointReactor | CheckpointStore.CheckpointStore - | ProjectionSnapshotQuery - | WorkspaceEntries.WorkspaceEntries, + | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -282,7 +279,6 @@ describe("CheckpointReactor", () => { readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; - readonly rollbackConversation?: ProviderServiceShape["rollbackConversation"]; readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); @@ -292,7 +288,6 @@ describe("CheckpointReactor", () => { options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, options?.providerName ?? ProviderDriverKind.make("codex"), - options?.rollbackConversation, ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -355,9 +350,6 @@ 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); @@ -423,8 +415,6 @@ describe("CheckpointReactor", () => { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, - checkpointStore, - workspaceEntries, cwd, drain, }; @@ -979,81 +969,6 @@ describe("CheckpointReactor", () => { ).toBe(false); }); - it("does not roll back provider conversation when filesystem restore fails", async () => { - const harness = await createHarness(); - const createdAt = "2026-01-01T00:00:00.000Z"; - vi.spyOn(harness.checkpointStore, "restoreCheckpoint").mockImplementationOnce(() => - Effect.succeed(false), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-restore-fails"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-restore-fails-diff-1"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-restore-fails-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-restore-fails-diff-2"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-restore-fails-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), - status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-revert-restore-fails"), - threadId: ThreadId.make("thread-1"), - turnCount: 1, - createdAt, - }), - ); - - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ); - - expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( - true, - ); - expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); - expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); - }); - it("executes provider revert and emits thread.reverted for claude sessions", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); const createdAt = "2026-01-01T00:00:00.000Z"; @@ -1123,191 +1038,11 @@ describe("CheckpointReactor", () => { }); }); - it("restores current checkpoint files when provider rollback is unsupported", async () => { - const harness = await createHarness({ - providerName: ProviderDriverKind.make("copilot"), - rollbackConversation: () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: "copilot", - method: "thread.rollback", - detail: "Copilot SDK does not expose thread rollback.", - }), - ), - }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-copilot"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "copilot", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-diff-copilot-1"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-copilot-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-diff-copilot-2"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-copilot-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), - status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-revert-copilot-request"), - threadId: ThreadId.make("thread-1"), - turnCount: 1, - createdAt, - }), - ); - - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ); - - expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( - true, - ); - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), - ).toBe(true); - }); - - it("refreshes workspace entries when provider rollback and recovery restore fail", async () => { - const harness = await createHarness({ - providerName: ProviderDriverKind.make("copilot"), - rollbackConversation: () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: "copilot", - method: "thread.rollback", - detail: "Copilot SDK does not expose thread rollback.", - }), - ), - }); - const createdAt = "2026-01-01T00:00:00.000Z"; - const refresh = vi - .spyOn(harness.workspaceEntries, "refresh") - .mockImplementation(() => Effect.void); - const restoreCheckpoint = harness.checkpointStore.restoreCheckpoint; - let restoreCalls = 0; - vi.spyOn(harness.checkpointStore, "restoreCheckpoint").mockImplementation((input) => { - restoreCalls += 1; - if (restoreCalls === 2) { - return Effect.succeed(false); - } - return restoreCheckpoint(input); - }); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-recovery-restore-fails"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "copilot", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-recovery-restore-fails-diff-1"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-recovery-restore-fails-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-recovery-restore-fails-diff-2"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-recovery-restore-fails-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), - status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-revert-recovery-restore-fails"), - threadId: ThreadId.make("thread-1"), - turnCount: 1, - createdAt, - }), - ); - - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ); - - expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( - true, - ); - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(restoreCalls).toBe(2); - expect(refresh).toHaveBeenCalledWith(harness.cwd); - }); - it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-inline-revert"), @@ -1325,7 +1060,7 @@ describe("CheckpointReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make("cmd-inline-revert-diff-1"), @@ -1339,7 +1074,7 @@ describe("CheckpointReactor", () => { createdAt, }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make("cmd-inline-revert-diff-2"), @@ -1354,7 +1089,7 @@ describe("CheckpointReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-sequenced-revert-request-1"), @@ -1363,7 +1098,7 @@ describe("CheckpointReactor", () => { createdAt, }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.checkpoint.revert", commandId: CommandId.make("cmd-sequenced-revert-request-0"), @@ -1390,7 +1125,7 @@ describe("CheckpointReactor", () => { const harness = await createHarness({ hasSession: false }); const createdAt = "2026-01-01T00:00:00.000Z"; - await runtime!.runPromise( + await Effect.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 7d7e81ac0b3a..3ba244ddf2c4 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -675,31 +675,6 @@ const make = Effect.gen(function* () { return; } - const targetCheckpointAvailable = - event.payload.turnCount === 0 - ? true - : yield* checkpointStore.hasCheckpointRef({ - cwd: sessionRuntime.value.cwd, - checkpointRef: targetCheckpointRef, - }); - if (!targetCheckpointAvailable) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } - - const currentCheckpointRef = - currentTurnCount === 0 - ? checkpointRefForThreadTurn(event.payload.threadId, 0) - : thread.checkpoints.find( - (checkpoint) => checkpoint.checkpointTurnCount === currentTurnCount, - )?.checkpointRef; - - const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); const restored = yield* checkpointStore.restoreCheckpoint({ cwd: sessionRuntime.value.cwd, checkpointRef: targetCheckpointRef, @@ -715,57 +690,18 @@ const make = Effect.gen(function* () { return; } - const rollbackFailureDetail: string | null = - rolledBackTurns > 0 - ? yield* providerService - .rollbackConversation({ - threadId: sessionRuntime.value.threadId, - numTurns: rolledBackTurns, - }) - .pipe( - Effect.as(null), - Effect.catch((error) => - Effect.gen(function* () { - const detail = currentCheckpointRef - ? yield* checkpointStore - .restoreCheckpoint({ - cwd: sessionRuntime.value.cwd, - checkpointRef: currentCheckpointRef, - fallbackToHead: currentTurnCount === 0, - }) - .pipe( - Effect.map((restoredCurrent) => - restoredCurrent - ? `Provider rollback failed after filesystem restore: ${error.message}. Filesystem was restored to the current checkpoint.` - : `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint.`, - ), - Effect.catch((restoreError) => - Effect.succeed( - `Provider rollback failed after filesystem restore: ${error.message}. Failed to restore filesystem to the current checkpoint: ${restoreError.message}`, - ), - ), - ) - : `Provider rollback failed after filesystem restore: ${error.message}. Current checkpoint ref is unavailable.`; - yield* workspaceEntries.refresh(sessionRuntime.value.cwd); - return detail; - }), - ), - ) - : null; - if (rollbackFailureDetail !== null) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: rollbackFailureDetail, - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } - // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); + if (rolledBackTurns > 0) { + yield* providerService.rollbackConversation({ + threadId: sessionRuntime.value.threadId, + numTurns: rolledBackTurns, + }); + } + const staleCheckpointRefs: Array = []; for (const checkpoint of thread.checkpoints) { if (checkpoint.checkpointTurnCount > event.payload.turnCount) { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 814c47125ba2..f12df850941f 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 ?? existingRow.value.latestTurnId, + latestTurnId: event.payload.session.activeTurnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5625fbe4a2de..ce464565dc5f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,7 +22,6 @@ 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"; @@ -144,7 +143,6 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; - readonly textGenerationModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; }) { @@ -311,13 +309,7 @@ describe("ProviderCommandReactor", () => { getInstanceInfo: (instanceId) => { const raw = String(instanceId); const driverKind = ProviderDriverKind.make( - raw.startsWith("claude") - ? "claudeAgent" - : raw.startsWith("codex") - ? "codex" - : raw.startsWith("copilot") - ? "copilot" - : raw, + raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, ); return Effect.succeed({ instanceId, @@ -376,13 +368,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge( - ServerSettingsService.layerTest( - input?.textGenerationModelSelection !== undefined - ? { textGenerationModelSelection: input.textGenerationModelSelection } - : {}, - ), - ), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -530,210 +516,6 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); - it("waits for first-turn thread title generation when draining", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - const seededTitle = "Please investigate reconnect failures after restar..."; - const releaseTitleGeneration = await runtime!.runPromise(Deferred.make()); - harness.generateThreadTitle.mockReturnValue( - Deferred.await(releaseTitleGeneration).pipe( - Effect.as({ - title: "Generated title", - }), - ), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-drain-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-drain"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-drain"), - role: "user", - text: "Please investigate reconnect failures after restarting the session.", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - let drained = false; - const drainPromise = harness.drain().then(() => { - drained = true; - }); - await runtime!.runPromise(Effect.yieldNow); - expect(drained).toBe(false); - - await runtime!.runPromise( - Deferred.succeed(releaseTitleGeneration, undefined).pipe(Effect.orDie), - ); - await drainPromise; - - expect(harness.sendTurn).toHaveBeenCalledOnce(); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Generated title"); - }); - - it("does not retry 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", - }), - ), - ); - - 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 === 1); - await harness.drain(); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(harness.generateThreadTitle).toHaveBeenCalledOnce(); - expect(thread?.title).toBe(seededTitle); - }); - - it("generates a summarized title while starting the first Copilot turn", async () => { - const copilotSelection = createModelSelection(ProviderInstanceId.make("copilot"), "gpt-4.1"); - const harness = await createHarness({ - threadModelSelection: copilotSelection, - textGenerationModelSelection: copilotSelection, - }); - const now = "2026-01-01T00:00:00.000Z"; - const seededTitle = "Investigate Copilot thread startup"; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ title: "Copilot startup errors" }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-copilot-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-copilot-title-skip"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-copilot-title-skip"), - role: "user", - text: "Investigate Copilot thread startup errors.", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await harness.drain(); - - expect(harness.generateThreadTitle).toHaveBeenCalledOnce(); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Copilot startup errors"); - }); - - it("replaces the default Copilot title with the local fallback", 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"; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ title: "Investigate Copilot reconnect failures" }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-copilot-default"), - threadId: ThreadId.make("thread-1"), - title: "New thread", - }), - ); - - await runtime!.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-copilot-title-fallback"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-copilot-title-fallback"), - role: "user", - text: "Investigate Copilot reconnect failures after restarting the session.", - attachments: [], - }, - titleSeed: "Investigate Copilot reconnect failures after...", - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - await harness.drain(); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Investigate Copilot reconnect failures"); - }); - 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"; @@ -825,186 +607,6 @@ 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 res..."; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ - title: "Reconnect startup reliability", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-provider-expanded-prompt"), - threadId: ThreadId.make("thread-1"), - title: fullPromptTitle, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-provider-expanded-prompt"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-provider-expanded-prompt"), - role: "user", - text: fullPromptTitle, - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - await waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Reconnect startup reliability" - ); - }); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Reconnect startup reliability"); - }); - - it("replaces provider-expanded first prompt titles that match mobile title seeds", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - const fullPromptTitle = - "Please investigate reconnect failures after restarting the session and make the startup path reliable."; - const seededTitle = "Please investigate reconnect failures after restarting the session and..."; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ - title: "Reconnect startup reliability", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-mobile-seed"), - threadId: ThreadId.make("thread-1"), - title: fullPromptTitle, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-mobile-seed"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-mobile-seed"), - role: "user", - text: fullPromptTitle, - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - await waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Reconnect startup reliability" - ); - }); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Reconnect startup reliability"); - }); - - it("does not replace user-edited titles that extend a truncated seed", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - const seededTitle = "Please investigate reconnect failures after restar..."; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-edited-truncated-seed"), - threadId: ThreadId.make("thread-1"), - title: `${seededTitle} (urgent)`, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-edited-truncated-seed"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-edited-truncated-seed"), - role: "user", - text: "Please investigate reconnect failures after restarting the session.", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.generateThreadTitle).not.toHaveBeenCalled(); - }); - - it("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"; @@ -1059,65 +661,6 @@ 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"; @@ -2224,7 +1767,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval"), @@ -2242,7 +1785,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond"), @@ -2265,7 +1808,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input"), @@ -2283,7 +1826,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond"), @@ -2319,7 +1862,7 @@ describe("ProviderCommandReactor", () => { ), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -2337,7 +1880,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -2358,7 +1901,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -2414,7 +1957,7 @@ describe("ProviderCommandReactor", () => { ), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -2432,7 +1975,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2465,7 +2008,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), @@ -2514,7 +2057,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await runtime!.runPromise( + await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-stop"), @@ -2533,7 +2076,7 @@ describe("ProviderCommandReactor", () => { }), ); - await runtime!.runPromise( + await Effect.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 8cc27ecf48f7..9c7a7c94bb10 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -41,7 +41,6 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; -import { canReplaceThreadTitle } from "../threadTitle.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -87,6 +86,7 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => 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"; export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -103,6 +103,18 @@ export function providerErrorLabelFromInstanceHint(input: { ); } +function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} + function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -654,6 +666,7 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, @@ -698,6 +711,7 @@ const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, @@ -708,7 +722,7 @@ const make = Effect.gen(function* () { const thread = yield* resolveThread(input.threadId); if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed, input.messageText)) { + if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { return; } @@ -771,23 +785,19 @@ const make = Effect.gen(function* () { ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; - yield* firstTurnAuxiliaryWorker.enqueue( - maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, + cwd: generationCwd, ...generationInput, - }), - ); - - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed, message.text)) { - yield* firstTurnAuxiliaryWorker.enqueue( - maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }), - ); + }).pipe(Effect.forkScoped); } } @@ -1048,9 +1058,6 @@ const make = Effect.gen(function* () { }), ); - const firstTurnAuxiliaryWorker = yield* makeDrainableWorker((job: Effect.Effect) => job, { - concurrency: "unbounded", - }); const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { @@ -1074,7 +1081,7 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain.pipe(Effect.andThen(firstTurnAuxiliaryWorker.drain), Effect.asVoid), + drain: worker.drain, } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 199380c9c04d..001ba3889496 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -27,9 +27,7 @@ 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"; @@ -135,9 +133,6 @@ function createProviderServiceHarness() { } runtimeSessions.push(session); }; - const clearSessions = (): void => { - runtimeSessions.length = 0; - }; const normalizeLegacyEvent = (event: LegacyProviderRuntimeEvent): ProviderRuntimeEvent => { if (isLegacyTurnCompletedEvent(event)) { @@ -162,7 +157,6 @@ function createProviderServiceHarness() { service, emit, setSession, - clearSessions, }; } @@ -172,10 +166,6 @@ 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, @@ -227,20 +217,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { - serverSettings?: Partial; - initialThreadTitle?: string; - }) { + async function createHarness(options?: { serverSettings?: Partial }) { 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), @@ -260,7 +240,6 @@ 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); @@ -292,7 +271,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: options?.initialThreadTitle ?? "Thread", + title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -335,9 +314,7 @@ describe("ProviderRuntimeIngestion", () => { readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, - clearProviderSessions: provider.clearSessions, drain, - logs, }; } @@ -383,144 +360,6 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); - it("settles unscoped turn completion while provider reports the active turn", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-missing-completion-id"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: now, - turnId: asTurnId("turn-missing-completion-id"), - }); - - await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-missing-completion-id", - ); - - harness.setProviderSession({ - provider: ProviderDriverKind.make("codex"), - status: "running", - runtimeMode: "approval-required", - threadId: ThreadId.make("thread-1"), - activeTurnId: asTurnId("turn-missing-completion-id"), - createdAt: now, - updatedAt: now, - }); - - harness.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-completed-missing-completion-id"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: "2026-01-01T00:00:00.100Z", - status: "completed", - }); - - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.session?.status === "ready" && - entry.session?.activeTurnId === null && - entry.latestTurn?.state === "completed", - ); - expect(thread.latestTurn?.turnId).toBe("turn-missing-completion-id"); - }); - - it("settles unscoped turn completion after provider clears the active turn", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-provider-cleared"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: now, - turnId: asTurnId("turn-provider-cleared"), - }); - - await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-provider-cleared", - ); - - harness.setProviderSession({ - provider: ProviderDriverKind.make("codex"), - status: "ready", - runtimeMode: "approval-required", - threadId: ThreadId.make("thread-1"), - createdAt: now, - updatedAt: now, - }); - - harness.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-completed-provider-cleared"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: "2026-01-01T00:00:00.100Z", - status: "completed", - }); - - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.session?.status === "ready" && - entry.session?.activeTurnId === null && - entry.latestTurn?.state === "completed", - ); - expect(thread.latestTurn?.turnId).toBe("turn-provider-cleared"); - }); - - it("rejects unscoped turn completion when no provider session exists", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-sessionless-completion"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: now, - turnId: asTurnId("turn-sessionless-completion"), - }); - - await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-sessionless-completion", - ); - - harness.clearProviderSessions(); - - harness.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-completed-sessionless"), - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("thread-1"), - createdAt: "2026-01-01T00:00:00.100Z", - status: "completed", - }); - - await harness.drain(); - - const thread = await waitForThread( - harness.readModel, - (entry) => entry.session?.activeTurnId === "turn-sessionless-completion", - ); - expect(thread.session?.status).toBe("running"); - expect(thread.latestTurn?.state).toBe("running"); - }); - it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; @@ -823,45 +662,6 @@ 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"; @@ -2731,29 +2531,8 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(true); }); - it("does not replace a custom thread title from provider metadata", async () => { - const harness = await createHarness(); - - harness.emit({ - type: "thread.metadata.updated", - eventId: asEventId("evt-thread-custom-title-metadata-updated"), - provider: ProviderDriverKind.make("copilot"), - createdAt: "2026-01-01T00:00:00.000Z", - threadId: asThreadId("thread-1"), - payload: { - name: "Renamed by provider", - }, - }); - - await harness.drain(); - - const snapshot = await harness.readModel(); - const thread = snapshot.threads.find((entry) => entry.id === "thread-1"); - expect(thread?.title).toBe("Thread"); - }); - it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { - const harness = await createHarness({ initialThreadTitle: "New thread" }); + const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ @@ -2886,48 +2665,6 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); - it("does not persist streamed deltas as 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-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", - }, - }); - await harness.drain(); - const thread = (await harness.readModel()).threads.find( - (entry) => entry.id === ThreadId.make("thread-1"), - ); - expect( - thread?.activities.some((activity) => - ["evt-reasoning-delta", "evt-command-output-delta"].includes(activity.id), - ), - ).toBe(false); - }); - 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 a26a43a69c0c..3e5978f4846d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -38,7 +38,6 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { canReplaceThreadTitle } from "../threadTitle.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1150,16 +1149,10 @@ 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 session = yield* getProviderSessionForThread(threadId); + const sessions = yield* providerService.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); return session?.activeTurnId; }, ); @@ -1228,31 +1221,10 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const lifecycleEventTurnId = eventTurnId; - const providerSessionForUnscopedCompletion = - event.type === "turn.completed" && eventTurnId === undefined - ? yield* getProviderSessionForThread(thread.id).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.as(undefined)), - ), - ) - : undefined; - const providerActiveTurnId = providerSessionForUnscopedCompletion?.activeTurnId; - const unscopedCompletionHasNoProviderSession = - event.type === "turn.completed" && - eventTurnId === undefined && - providerSessionForUnscopedCompletion === undefined; const conflictsWithActiveTurn = - activeTurnId !== null && - lifecycleEventTurnId !== undefined && - !sameId(activeTurnId, lifecycleEventTurnId); + activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); + const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; // 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 @@ -1283,18 +1255,12 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": - if (conflictsWithActiveTurn) { + if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } - if (activeTurnId !== null && lifecycleEventTurnId === undefined) { - return ( - !unscopedCompletionHasNoProviderSession && - (providerActiveTurnId === undefined || sameId(activeTurnId, providerActiveTurnId)) - ); - } // Only the active turn may close the lifecycle state. - if (activeTurnId !== null && lifecycleEventTurnId !== undefined) { - return sameId(activeTurnId, lifecycleEventTurnId); + if (activeTurnId !== null && eventTurnId !== undefined) { + return sameId(activeTurnId, eventTurnId); } // If no active turn is tracked, accept completion scoped to this thread. return true; @@ -1302,53 +1268,6 @@ 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) @@ -1624,9 +1543,7 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; - const turnId = - toTurnId(event.turnId) ?? - (shouldApplyThreadLifecycle ? (activeTurnId ?? providerActiveTurnId ?? null) : null); + const turnId = toTurnId(event.turnId); if (turnId) { const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); yield* Effect.forEach( @@ -1691,11 +1608,7 @@ const make = Effect.gen(function* () { } } - if ( - event.type === "thread.metadata.updated" && - event.payload.name && - canReplaceThreadTitle(thread.title) - ) { + if (event.type === "thread.metadata.updated" && event.payload.name) { yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: yield* providerCommandId(event, "thread-meta-update"), diff --git a/apps/server/src/orchestration/threadTitle.ts b/apps/server/src/orchestration/threadTitle.ts deleted file mode 100644 index 6533eeea7fdd..000000000000 --- a/apps/server/src/orchestration/threadTitle.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { truncate } from "@t3tools/shared/String"; - -const DEFAULT_THREAD_TITLE = "New thread"; - -function normalizeTitleSeedCandidate(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -export function canReplaceThreadTitle( - currentTitle: string, - titleSeed?: string, - sourceMessageText?: string, -): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - if (trimmedTitleSeed === undefined || trimmedTitleSeed.length === 0) { - 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; - } - - return ( - truncate(trimmedCurrentTitle, Math.max(0, trimmedTitleSeed.length - 3)) === trimmedTitleSeed - ); -} diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 78542438e100..29e92e9f40b3 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -225,34 +225,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect( - "approves bootstrap permission requests before the session context exists in full-access mode", - () => - Effect.gen(function* () { - runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - NodeAssert.ok(config.onPermissionRequest); - const result = await config.onPermissionRequest({ kind: "shell" } as PermissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }); - NodeAssert.deepStrictEqual(result, { kind: "approve-once" }); - return runtimeMock.state.lastSession as unknown as CopilotSession; - }; - - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-bootstrap-permission-approved"); - - const session = yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - NodeAssert.equal(session.provider, "copilot"); - yield* adapter.stopSession(threadId); - }), - ); - it.effect("correlates bootstrap auto-approvals before replaying early permission events", () => Effect.gen(function* () { const permissionRequest = { @@ -384,171 +356,6 @@ 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) => { - NodeAssert.ok(config.onPermissionRequest); - const shellResult = await config.onPermissionRequest( - { kind: "shell" } as PermissionRequest, - { - sessionId: runtimeMock.state.lastSession.sessionId, - }, - ); - const writeResult = await config.onPermissionRequest( - { kind: "write" } as PermissionRequest, - { - sessionId: runtimeMock.state.lastSession.sessionId, - }, - ); - NodeAssert.deepStrictEqual(shellResult, { kind: "reject" }); - NodeAssert.deepStrictEqual(writeResult, { kind: "approve-once" }); - return runtimeMock.state.lastSession as unknown as CopilotSession; - }; - - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-bootstrap-auto-accept-edits"); - - const session = yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "auto-accept-edits", - }); - - NodeAssert.equal(session.provider, "copilot"); - NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.mode.set.mock.calls.at(-1), [ - { mode: "interactive" }, - ]); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect( - "returns an empty bootstrap user input response before the session context exists", - () => - Effect.gen(function* () { - runtimeMock.state.createSessionImpl = async (config: SessionConfig) => { - NodeAssert.ok(config.onUserInputRequest); - const response = await config.onUserInputRequest( - { - question: "How should Copilot continue?", - choices: ["Continue"], - allowFreeform: true, - }, - { sessionId: runtimeMock.state.lastSession.sessionId }, - ); - NodeAssert.deepStrictEqual(response, { - answer: "", - wasFreeform: true, - }); - return runtimeMock.state.lastSession as unknown as CopilotSession; - }; - - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-bootstrap-user-input"); - - const session = yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - NodeAssert.equal(session.provider, "copilot"); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("registers MCP auth and supplies the T3 bearer token on session creation", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-mcp-auth-create"); - const accessToken = "copilot-mcp-create-token"; - setMcpProviderSession(threadId, accessToken); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onMcpAuthRequest); - NodeAssert.equal(config.mcpOAuthTokenStorage, "in-memory"); - NodeAssert.deepStrictEqual(config.mcpServers, { - "t3-code": { - type: "http", - url: "http://127.0.0.1:43123/mcp", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - }); - - const result = yield* Effect.promise(() => - Promise.resolve( - config.onMcpAuthRequest?.( - { - requestId: "mcp-auth-create-request", - serverName: "t3-code", - serverUrl: "http://127.0.0.1:43123/mcp/", - reason: "initial", - }, - { sessionId: runtimeMock.state.lastSession.sessionId }, - ), - ), - ); - NodeAssert.deepStrictEqual(result, { - kind: "token", - accessToken, - tokenType: "Bearer", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("registers MCP auth on resume and cancels when no T3 token is available", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-mcp-auth-resume"); - const sdkSessionId = "persisted-copilot-mcp-session"; - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - resumeCursor: { - schemaVersion: 1, - sessionId: sdkSessionId, - }, - }); - - const config = runtimeMock.state.resumeSessionCalls.at(-1)?.config; - NodeAssert.ok(config?.onMcpAuthRequest); - NodeAssert.equal(config.mcpOAuthTokenStorage, "in-memory"); - NodeAssert.equal(config.mcpServers, undefined); - const result = yield* Effect.promise(() => - Promise.resolve( - config.onMcpAuthRequest?.( - { - requestId: "mcp-auth-resume-request", - serverName: "external", - serverUrl: "https://mcp.example.test/rpc", - reason: "reauth", - }, - { sessionId: sdkSessionId }, - ), - ), - ); - NodeAssert.deepStrictEqual(result, { kind: "cancelled" }); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("maps correlated MCP lifecycle events without leaking auth secrets", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -1028,63 +835,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("applies plan and one-time auto-switch policies to resumed sessions", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-plan-exit-policy-resume"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - resumeCursor: { - schemaVersion: 1, - sessionId: "persisted-copilot-session", - }, - }); - - const config = runtimeMock.state.resumeSessionCalls.at(-1)?.config; - NodeAssert.ok(config?.onExitPlanModeRequest); - NodeAssert.ok(config.onAutoModeSwitchRequest); - NodeAssert.deepStrictEqual( - yield* Effect.promise(() => - Promise.resolve( - config.onExitPlanModeRequest?.( - { - summary: "Persisted plan", - actions: ["exit_only"], - recommendedAction: "exit_only", - }, - { - sessionId: runtimeMock.state.lastSession.sessionId, - }, - ), - ), - ), - { approved: false }, - ); - NodeAssert.equal( - yield* Effect.promise(() => - Promise.resolve( - config.onAutoModeSwitchRequest?.( - { - errorCode: "user_weekly_rate_limited", - retryAfterSeconds: 60, - }, - { - sessionId: runtimeMock.state.lastSession.sessionId, - }, - ), - ), - ), - "yes", - ); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("starts a fresh session when the persisted Copilot resume cursor is missing", () => Effect.gen(function* () { runtimeMock.state.resumeSessionImpl = async (sessionId: string) => { @@ -1122,26 +872,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("force stops the Copilot client when graceful session cleanup is incomplete", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-incomplete-client-cleanup"); - runtimeMock.state.stopErrors = [new Error("Copilot process remained alive")]; - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - yield* adapter.stopSession(threadId); - - NodeAssert.equal(runtimeMock.state.stopCalls, 1); - NodeAssert.equal(runtimeMock.state.forceStopCalls, 1); - NodeAssert.equal(yield* adapter.hasSession(threadId), false); - }), - ); - it.effect("rolls back resumed history without local turn snapshots", () => Effect.gen(function* () { const timestamp = yield* nowIso; @@ -1230,51 +960,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("stops a session that is still starting when stopAll begins", () => - Effect.gen(function* () { - let releaseCreateSession!: () => void; - const createSessionGate = new Promise((resolve) => { - releaseCreateSession = resolve; - }); - let markCreateSessionStarted!: () => void; - const createSessionStarted = new Promise((resolve) => { - markCreateSessionStarted = resolve; - }); - runtimeMock.state.createSessionImpl = async () => { - markCreateSessionStarted(); - await createSessionGate; - return runtimeMock.state.lastSession as unknown as CopilotSession; - }; - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-stop-all-during-start"); - - const startFiber = yield* adapter - .startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }) - .pipe(Effect.forkChild); - yield* Effect.promise(() => createSessionStarted); - - const stopAllFiber = yield* adapter.stopAll().pipe(Effect.forkChild); - yield* Effect.yieldNow; - NodeAssert.equal(runtimeMock.state.stopCalls, 0); - - releaseCreateSession(); - yield* Fiber.join(startFiber); - yield* Fiber.join(stopAllFiber); - - NodeAssert.equal(runtimeMock.state.stopCalls, 1); - NodeAssert.equal(yield* adapter.hasSession(threadId), false); - }), - ); - - it.effect("rolls back persisted Copilot history from the first removed user message", () => + it.effect("serializes rollback with SDK events and new sends", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-rollback-history"); + const threadId = asThreadId("copilot-rollback-serialization"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -1293,249 +982,33 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; - const completeTurn = (sdkTurnId: string, eventPrefix: string) => { - config.onEvent?.({ - id: `${eventPrefix}-start`, - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: sdkTurnId }, - } as SessionEvent); - config.onEvent?.({ - id: `${eventPrefix}-end`, - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { turnId: sdkTurnId }, - } as SessionEvent); - config.onEvent?.({ - id: `${eventPrefix}-idle`, - timestamp, - parentId: null, - type: "session.idle", - data: { aborted: false }, - } as SessionEvent); - }; - runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-first"); const firstTurn = yield* adapter.sendTurn({ threadId, input: "first prompt", attachments: [], }); - completeTurn("sdk-turn-first", "evt-first"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); - const secondTurn = yield* adapter.sendTurn({ - threadId, - input: "second prompt", - attachments: [], - }); - completeTurn("sdk-turn-second", "evt-second"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); - const thirdTurn = yield* adapter.sendTurn({ - threadId, - input: "third prompt", - attachments: [], - }); - // The second turn's SDK user event can arrive after that turn completed - // and after a new app turn was queued. It must bind to the oldest local - // turn missing a history boundary instead of hijacking the new queue. - config.onEvent?.({ - id: "subagent-user-message", - timestamp, - parentId: null, - type: "user.message", - agentId: "subagent-history", - data: { content: "subagent prompt" }, - } as SessionEvent); - config.onEvent?.({ - id: "user-message-second", - timestamp, - parentId: null, - type: "user.message", - data: { content: "second prompt" }, - } as SessionEvent); - config.onEvent?.({ - id: "user-message-third", - timestamp, - parentId: null, - type: "user.message", - data: { content: "third prompt" }, - } as SessionEvent); - completeTurn("sdk-turn-third", "evt-third"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(thirdTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - const snapshot = yield* adapter.rollbackThread(threadId, 2); - NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ - [{ eventId: "user-message-second" }], - ]); - NodeAssert.deepStrictEqual( - snapshot.turns.map((turn) => String(turn.id)), - [String(firstTurn.turnId)], - ); - NodeAssert.deepStrictEqual( - (yield* adapter.readThread(threadId)).turns.map((turn) => String(turn.id)), - [String(firstTurn.turnId)], - ); - - runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); - const recoveredTurn = yield* adapter.sendTurn({ - threadId, - input: "recover persisted boundary", - attachments: [], - }); - completeTurn("sdk-turn-recovered", "evt-recovered"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && - String(event.turnId) === String(recoveredTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - runtimeMock.state.lastSession.getEvents.mockResolvedValueOnce([ - { - id: "user-message-recovered", - timestamp, - parentId: null, - type: "user.message", - data: { content: "recover persisted boundary" }, - } as SessionEvent, - ]); - - const recoveredSnapshot = yield* adapter.rollbackThread(threadId, 1); - NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ - [{ eventId: "user-message-second" }], - [{ eventId: "user-message-recovered" }], - ]); - NodeAssert.deepStrictEqual( - recoveredSnapshot.turns.map((turn) => String(turn.id)), - [String(firstTurn.turnId)], - ); - - runtimeMock.state.lastSession.send.mockResolvedValueOnce(undefined); - const turnWithoutHistoryBoundary = yield* adapter.sendTurn({ - threadId, - input: "missing persisted boundary", - attachments: [], - }); - completeTurn("sdk-turn-without-boundary", "evt-without-boundary"); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && - String(event.turnId) === String(turnWithoutHistoryBoundary.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - const missingBoundaryError = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); - NodeAssert.match(missingBoundaryError.message, /contains only 0 root user messages/); - NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.rpc.history.truncate.mock.calls, [ - [{ eventId: "user-message-second" }], - [{ eventId: "user-message-recovered" }], - ]); - NodeAssert.deepStrictEqual( - (yield* adapter.readThread(threadId)).turns.map((turn) => String(turn.id)), - [String(firstTurn.turnId), String(turnWithoutHistoryBoundary.turnId)], - ); - - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("serializes rollback with SDK events and new sends", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-rollback-serialization"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - - const firstTurn = yield* adapter.sendTurn({ - threadId, - input: "first prompt", - attachments: [], - }); - config.onEvent?.({ - id: "rollback-serialization-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-first" }, - } as SessionEvent); - config.onEvent?.({ - id: "rollback-serialization-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { turnId: "sdk-turn-first" }, - } as SessionEvent); - config.onEvent?.({ - id: "rollback-serialization-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { aborted: false }, - } as SessionEvent); + config.onEvent?.({ + id: "rollback-serialization-start", + timestamp, + parentId: null, + type: "assistant.turn_start", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-serialization-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { turnId: "sdk-turn-first" }, + } as SessionEvent); + config.onEvent?.({ + id: "rollback-serialization-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { aborted: false }, + } as SessionEvent); for ( let attempt = 0; attempt < 20 && @@ -1608,10 +1081,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("retries interrupted stop before replacing a session", () => + it.effect("keeps assistant call usage on the turn without publishing context usage", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-rollback-stop-serialization"); + const threadId = asThreadId("copilot-assistant-call-usage"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -1627,337 +1100,73 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); - runtimeMock.state.lastSession.send.mockResolvedValueOnce("user-message-first"); const turn = yield* adapter.sendTurn({ threadId, - input: "first prompt", + input: "measure this turn", attachments: [], }); const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); const timestamp = yield* nowIso; - config.onEvent?.({ - id: "rollback-stop-start", + config.onEvent({ + id: "evt-call-usage-turn-start", timestamp, parentId: null, type: "assistant.turn_start", - data: { turnId: "sdk-turn-first" }, - } as SessionEvent); - config.onEvent?.({ - id: "rollback-stop-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { turnId: "sdk-turn-first" }, + data: { turnId: "sdk-turn-call-usage" }, } as SessionEvent); - config.onEvent?.({ - id: "rollback-stop-idle", + config.onEvent({ + id: "evt-call-usage", timestamp, parentId: null, - type: "session.idle", - data: { aborted: false }, + type: "assistant.usage", + ephemeral: true, + data: { + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 5, + duration: 250, + }, } 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* waitForSdkEventQueue(); + yield* adapter.stopSession(threadId); + yield* waitForSdkEventQueue(); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "thread.token-usage.updated"), + false, + ); + const completed = runtimeEvents.find( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.payload.usage, { + usedTokens: 125, + lastUsedTokens: 125, + inputTokens: 100, + lastInputTokens: 100, + cachedInputTokens: 20, + lastCachedInputTokens: 20, + outputTokens: 5, + lastOutputTokens: 5, + durationMs: 250, + }); } + }), + ); - let markTruncateStarted!: () => void; - const truncateStarted = new Promise((resolve) => { - markTruncateStarted = resolve; - }); - let releaseTruncate!: () => void; - const truncateGate = new Promise((resolve) => { - releaseTruncate = resolve; - }); - runtimeMock.state.lastSession.rpc.history.truncate.mockImplementationOnce(async () => { - markTruncateStarted(); - await truncateGate; - return { eventsRemoved: 1 }; - }); + it.effect("serializes each turn's model and mode through its SDK send", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-send-turn-configuration-serialization"); - let markRollbackFinished!: () => void; - const rollbackFinished = new Promise((resolve) => { - markRollbackFinished = resolve; - }); - const rollbackFiber = yield* adapter.rollbackThread(threadId, 1).pipe( - Effect.tap(() => Effect.sync(markRollbackFinished)), - Effect.forkChild, - ); - yield* Effect.promise(() => truncateStarted); - - let releaseNativeWrite: () => void = () => undefined; - const nativeWritesBeforeLateEvent = runtimeMock.state.nativeWriteCalls; - runtimeMock.state.nativeWriteGate = new Promise((resolve) => { - releaseNativeWrite = resolve; - }); - config.onEvent?.({ - id: "late-user-message-after-rollback", - timestamp, - parentId: null, - type: "user.message", - data: { content: "late SDK event" }, - } as SessionEvent); - - const interruptedStopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(interruptedStopFiber).pipe(Effect.ignore); - const sessionPresentAfterInterruptedStop = yield* adapter.hasSession(threadId); - const disconnectsAfterInterruptedStop = - runtimeMock.state.lastSession.disconnect.mock.calls.length; - - releaseTruncate(); - for ( - let attempt = 0; - attempt < 20 && runtimeMock.state.nativeWriteCalls === nativeWritesBeforeLateEvent; - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - const rollbackFinishedBeforeEventDrain = yield* Effect.promise(() => - Promise.race([ - rollbackFinished.then(() => true), - NodeTimersPromises.setTimeout(200).then(() => false), - ]), - ); - - const replacementFiber = yield* adapter - .startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }) - .pipe(Effect.forkChild); - yield* waitForSdkEventQueue(); - const sessionCreationsBeforeEventDrain = runtimeMock.state.createSessionConfigs.length; - const disconnectsBeforeEventDrain = - runtimeMock.state.lastSession.disconnect.mock.calls.length; - - releaseNativeWrite(); - const rolledBack = yield* Fiber.join(rollbackFiber); - yield* Fiber.join(replacementFiber); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(rollbackFinishedBeforeEventDrain, true); - NodeAssert.deepStrictEqual(rolledBack.turns, []); - NodeAssert.equal(sessionPresentAfterInterruptedStop, true); - NodeAssert.equal(disconnectsAfterInterruptedStop, 0); - NodeAssert.equal(sessionCreationsBeforeEventDrain, 1); - NodeAssert.equal(disconnectsBeforeEventDrain, 0); - NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 1); - NodeAssert.equal(runtimeMock.state.stopCalls, 1); - NodeAssert.equal(yield* adapter.hasSession(threadId), true); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("stops a replacement that wins the lifecycle lock before stop", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-stop-after-replacement"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - let markDisconnectStarted!: () => void; - const disconnectStarted = new Promise((resolve) => { - markDisconnectStarted = resolve; - }); - let releaseDisconnect!: () => void; - const disconnectGate = new Promise((resolve) => { - releaseDisconnect = resolve; - }); - runtimeMock.state.lastSession.disconnect.mockImplementationOnce(async () => { - markDisconnectStarted(); - await disconnectGate; - }); - - const replacementFiber = yield* adapter - .startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }) - .pipe(Effect.forkChild); - yield* Effect.promise(() => disconnectStarted); - - const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); - releaseDisconnect(); - yield* Fiber.join(replacementFiber); - yield* Fiber.join(stopFiber); - - NodeAssert.equal(runtimeMock.state.startCalls, 2); - NodeAssert.equal(runtimeMock.state.stopCalls, 2); - NodeAssert.equal(runtimeMock.state.lastSession.disconnect.mock.calls.length, 2); - NodeAssert.equal(yield* adapter.hasSession(threadId), false); - }), - ); - - it.effect("keeps assistant call usage on the turn without publishing context usage", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-assistant-call-usage"); - - 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 turn = yield* adapter.sendTurn({ - threadId, - input: "measure this turn", - attachments: [], - }); - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-call-usage-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-call-usage" }, - } as SessionEvent); - config.onEvent({ - id: "evt-call-usage", - timestamp, - parentId: null, - type: "assistant.usage", - ephemeral: true, - data: { - inputTokens: 100, - cacheReadTokens: 20, - outputTokens: 5, - duration: 250, - }, - } as SessionEvent); - yield* waitForSdkEventQueue(); - yield* adapter.stopSession(threadId); - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "thread.token-usage.updated"), - false, - ); - const completed = runtimeEvents.find( - (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - NodeAssert.equal(completed?.type, "turn.completed"); - if (completed?.type === "turn.completed") { - NodeAssert.deepStrictEqual(completed.payload.usage, { - usedTokens: 125, - lastUsedTokens: 125, - inputTokens: 100, - lastInputTokens: 100, - cachedInputTokens: 20, - lastCachedInputTokens: 20, - outputTokens: 5, - lastOutputTokens: 5, - durationMs: 250, - }); - } - }), - ); - - it.effect("passes selected Copilot context tier when changing models for a turn", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-send-turn-context-tier"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - runtimeMock.state.lastSession.setModel.mockClear(); - yield* adapter.sendTurn({ - threadId, - input: "Use the long context tier", - attachments: [], - modelSelection: { - instanceId: COPILOT_INSTANCE_ID, - model: "claude-sonnet-4.6", - options: [{ id: "contextTier", value: "long_context" }], - }, - }); - - NodeAssert.deepStrictEqual(runtimeMock.state.lastSession.setModel.mock.calls.at(-1), [ - "claude-sonnet-4.6", - { contextTier: "long_context" }, - ]); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("does not send a turn when Copilot mode synchronization fails", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-send-mode-sync-failure"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - runtimeMock.state.lastSession.rpc.mode.set.mockRejectedValueOnce( - new Error("Copilot mode update rejected"), - ); - runtimeMock.state.lastSession.send.mockClear(); - const result = yield* adapter - .sendTurn({ - threadId, - input: "do not send this prompt", - attachments: [], - interactionMode: "plan", - }) - .pipe(Effect.result); - - NodeAssert.equal(result._tag, "Failure"); - NodeAssert.equal(runtimeMock.state.lastSession.send.mock.calls.length, 0); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("serializes each turn's model and mode through its SDK send", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-send-turn-configuration-serialization"); - - 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", }); runtimeMock.state.lastSession.setModel.mockClear(); @@ -2153,10 +1362,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("returns SDK session approval without path prompt approval for reads", () => + 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-read-permission-accept-for-session"); + const threadId = asThreadId("copilot-task-complete-assistant-fallback"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2165,145 +1374,33 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); + const turn = yield* adapter.sendTurn({ + threadId, + input: "make an architecture diagram", + attachments: [], + }); - 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 runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + const emit = (event: SessionEvent) => config.onEvent?.(event); + const resultText = + "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-read-permission-session-approval", + emit({ + id: "evt-copilot-turn-start", timestamp, parentId: null, - type: "permission.requested", + type: "assistant.turn_start", data: { - requestId, - permissionRequest, - }, - } as SessionEvent); - yield* waitForSdkEventQueue(); - - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(requestId), - "acceptForSession", - ); - - const result = yield* Effect.promise(() => resultPromise); - NodeAssert.deepStrictEqual(result, { kind: "approve-for-session" }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("returns a session-scoped SDK domain approval for URL acceptForSession", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-url-permission-accept-for-session"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); - - const permissionRequest: PermissionRequest = { - kind: "url", - toolCallId: "tool-url-session-approval", - url: "https://docs.github.com/en/copilot", - intention: "Fetch Copilot documentation", - }; - const requestId = "permission-url-session-approval"; - const resultPromise = Promise.resolve( - config.onPermissionRequest(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }), - ); - const timestamp = yield* nowIso; - - config.onEvent({ - id: "evt-copilot-url-permission-session-approval", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - }, - } as SessionEvent); - yield* waitForSdkEventQueue(); - - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(requestId), - "acceptForSession", - ); - - const result = yield* Effect.promise(() => resultPromise); - NodeAssert.deepStrictEqual(result, { - kind: "approve-for-session", - domain: "docs.github.com", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("renders Copilot Task_complete output as assistant text instead of a tool call", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-task-complete-assistant-fallback"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "make an architecture diagram", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const resultText = - "Task completed: **Architecture diagram prepared**\n\n```mermaid\nflowchart TD\n Client --> Server\n```"; - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-1", + turnId: "sdk-turn-1", }, } as SessionEvent); emit({ @@ -2738,10 +1835,10 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("does not render the file-change completion fallback as assistant text", () => + 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-file-change-fallback-filter"); + const threadId = asThreadId("copilot-apply-patch-turn-diff"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2767,90 +1864,84 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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-file-change-turn-start", + id: "evt-copilot-apply-patch-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-file-change", + turnId: "sdk-turn-apply-patch", }, } as SessionEvent); emit({ - id: "evt-copilot-edit-start", + id: "evt-copilot-apply-patch-start", timestamp, parentId: null, type: "tool.execution_start", data: { - toolCallId: "tool-edit-file", - toolName: "edit_file", + toolCallId: "tool-apply-patch", + toolName: "Apply_patch", arguments: { - path: "README.md", + patch, }, }, } as SessionEvent); emit({ - id: "evt-copilot-edit-complete", + id: "evt-copilot-apply-patch-complete", timestamp, parentId: null, type: "tool.execution_complete", data: { - toolCallId: "tool-edit-file", + toolCallId: "tool-apply-patch", success: true, - result: {}, + result: { + content: patch, + }, }, } as SessionEvent); emit({ - id: "evt-copilot-file-change-turn-end", + id: "evt-copilot-apply-patch-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-file-change", + turnId: "sdk-turn-apply-patch", }, } as SessionEvent); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - attempt += 1 - ) { + 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 thread = yield* adapter.readThread(threadId); - const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - NodeAssert.ok(turnSnapshot); - const assistantItems = turnSnapshot.items.filter( - (item) => - typeof item === "object" && - item !== null && - "type" in item && - item.type === "assistant_message", - ); - NodeAssert.deepStrictEqual(assistantItems, []); - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "content.delta" && event.payload.streamKind === "assistant_text", - ), - false, + NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); + if (diffEvent?.type === "turn.diff.updated") { + NodeAssert.equal(diffEvent.turnId, turn.turnId); + NodeAssert.deepStrictEqual(diffEvent.payload, { + unifiedDiff: patch, + }); + } + + const completedTool = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "file_change" && + String(event.itemId) === "copilot-tool-tool-apply-patch", ); + NodeAssert.equal(completedTool?.type, "item.completed"); yield* adapter.stopSession(threadId); }), ); - it.effect("does not render the generic tool completion fallback as assistant text", () => + it.effect("emits turn diff when a Copilot write permission is approved", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-generic-tool-fallback-filter"); + const threadId = asThreadId("copilot-write-permission-turn-diff"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -2861,7 +1952,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const turn = yield* adapter.sendTurn({ threadId, - input: "inspect the README", + input: "update the README", attachments: [], }); @@ -2874,122 +1965,109 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onPermissionRequest); const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; + const requestId = "permission-write-readme"; + const permissionRequest = { + kind: "write", + toolCallId: "tool-write-readme", + fileName: "README.md", + diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", + intention: "Update README", + canOfferSessionApproval: true, + } as Extract; emit({ - id: "evt-copilot-generic-tool-turn-start", + id: "evt-copilot-write-permission-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-generic-tool", + turnId: "sdk-turn-write-permission", }, } as SessionEvent); + + const resultPromise = Promise.resolve( + config.onPermissionRequest(permissionRequest, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); emit({ - id: "evt-copilot-generic-tool-start", + id: "evt-copilot-write-permission-requested", timestamp, parentId: null, - type: "tool.execution_start", + type: "permission.requested", data: { - toolCallId: "tool-read-file", - toolName: "Read", - arguments: { - kind: "execute", - path: "README.md", - }, + requestId, + permissionRequest, + promptRequest: undefined, }, - } as SessionEvent); + } as unknown as SessionEvent); + + let opened: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + opened = runtimeEvents.find( + (event) => event.type === "request.opened" && String(event.requestId) === requestId, + ); + } + NodeAssert.equal(opened?.type, "request.opened"); + if (opened?.type === "request.opened") { + NodeAssert.equal(opened.payload.requestType, "file_change_approval"); + NodeAssert.equal(String(opened.turnId), String(turn.turnId)); + } + + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const approvalResult = yield* Effect.promise(() => resultPromise); + NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); + emit({ - id: "evt-copilot-generic-tool-complete", + id: "evt-copilot-write-permission-completed", timestamp, parentId: null, - type: "tool.execution_complete", + type: "permission.completed", data: { - toolCallId: "tool-read-file", - success: true, - result: { - content: "# Project", - }, + requestId, + result: approvalResult, }, } as SessionEvent); emit({ - id: "evt-copilot-generic-tool-turn-end", + id: "evt-copilot-write-permission-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-generic-tool", + turnId: "sdk-turn-write-permission", }, } 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* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const thread = yield* adapter.readThread(threadId); - const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - NodeAssert.ok(turnSnapshot); - const assistantItems = turnSnapshot.items.filter( - (item) => - typeof item === "object" && - item !== null && - "type" in item && - item.type === "assistant_message", - ); - NodeAssert.deepStrictEqual(assistantItems, []); - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "content.delta" && event.payload.streamKind === "assistant_text", - ), - false, - ); - - const startedTool = runtimeEvents.find( - (event) => - event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-read-file", - ); - NodeAssert.equal(startedTool?.type, "item.started"); - if (startedTool?.type === "item.started") { - NodeAssert.ok( - startedTool.payload.data !== null && - typeof startedTool.payload.data === "object" && - !Array.isArray(startedTool.payload.data), - ); - const data = startedTool.payload.data as Record; - NodeAssert.equal(data.kind, "read"); + const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); + NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); + if (diffUpdated?.type === "turn.diff.updated") { + NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); + NodeAssert.deepStrictEqual(diffUpdated.payload, { + unifiedDiff: permissionRequest.diff.trim(), + }); } yield* adapter.stopSession(threadId); }), ); - it.effect("does not render the command-only completion fallback as assistant text", () => + it.effect("prompts for shell permissions in auto-accept-edits mode", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-only-fallback-filter"); + const threadId = asThreadId("copilot-auto-accept-edits-shell-permission"); yield* adapter.startSession({ provider: COPILOT_DRIVER, threadId, cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run the tests", - attachments: [], + runtimeMode: "auto-accept-edits", }); const runtimeEvents: ProviderRuntimeEvent[] = []; @@ -3001,200 +2079,68 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-command-only-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command-only", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-run-tests", - toolName: "bash", - arguments: { - command: "vp test", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-run-tests", - success: true, - result: { - content: "All tests passed.", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-only-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-command-only", - }, - } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const thread = yield* adapter.readThread(threadId); - const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - NodeAssert.ok(turnSnapshot); - const assistantItems = turnSnapshot.items.filter( - (item) => - typeof item === "object" && - item !== null && - "type" in item && - item.type === "assistant_message", - ); - NodeAssert.deepStrictEqual(assistantItems, []); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("does not render the generic completion fallback after a task-completed result", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-generic-fallback-task-completed-filter"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "delegate this task", - attachments: [], - }); + NodeAssert.ok(config.onPermissionRequest); - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, + 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, + }), ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; - emit({ - id: "evt-copilot-generic-task-completed-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-generic-task-completed", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-generic-task-completed-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-finish-work", - toolName: "finish_work", - arguments: { - description: "finish the work", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-generic-task-completed-complete", + config.onEvent({ + id: "evt-copilot-permission-auto-accept-edits", timestamp, parentId: null, - type: "tool.execution_complete", + type: "permission.requested", data: { - toolCallId: "tool-finish-work", - success: true, - result: { - content: "✓ Task completed: Updated the implementation.", + requestId, + permissionRequest, + promptRequest: { + kind: "commands", + toolCallId: "tool-shell-auto-accept-edits", + fullCommandText: "git status", + intention: "Check repository status", + commandIdentifiers: ["git"], + canOfferSessionApproval: true, }, }, } as SessionEvent); - emit({ - id: "evt-copilot-generic-task-completed-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-generic-task-completed", - }, - } as SessionEvent); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - attempt += 1 - ) { + 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, + ); } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.ok( - runtimeEvents.some( - (event) => - event.type === "item.completed" && - event.payload.itemType === "dynamic_tool_call" && - event.payload.detail === "✓ Task completed: Updated the implementation.", - ), - ); + NodeAssert.equal(opened?.type, "request.opened"); - const thread = yield* adapter.readThread(threadId); - const turnSnapshot = thread.turns.find((entry) => entry.id === turn.turnId); - NodeAssert.ok(turnSnapshot); - const assistantItems = turnSnapshot.items.filter( - (item) => - typeof item === "object" && - item !== null && - "type" in item && - item.type === "assistant_message", - ); - NodeAssert.deepStrictEqual(assistantItems, []); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); + const result = yield* Effect.promise(() => resultPromise); + NodeAssert.deepStrictEqual(result, { kind: "approve-once" }); + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); yield* adapter.stopSession(threadId); }), ); - it.effect("does not emit an empty turn diff when a Copilot file-change turn completes", () => + it.effect("emits Copilot background task lifecycle events without plan updates", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-file-change-turn-diff"); + const threadId = asThreadId("copilot-background-tasks-plan"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -3205,7 +2151,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* adapter.sendTurn({ threadId, - input: "edit the docs", + input: "delegate the investigation", attachments: [], }); @@ -3216,2410 +2162,97 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { ); yield* waitForSdkEventQueue(); + runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ + tasks: [ + { + type: "agent", + id: "task-explore-1", + toolCallId: "tool-task-explore-1", + description: "Exploring provider events", + status: "running", + startedAt: "2026-06-11T12:00:00.000Z", + agentType: "explore", + prompt: "Find Copilot task events", + }, + { + type: "shell", + id: "task-shell-1", + description: "Running tests", + status: "completed", + startedAt: "2026-06-11T12:00:01.000Z", + command: "vp test", + attachmentMode: "detached", + }, + { + type: "agent", + id: "task-review-1", + toolCallId: "tool-task-review-1", + description: "Reviewing implementation", + status: "failed", + startedAt: "2026-06-11T12:00:02.000Z", + agentType: "code-review", + prompt: "Review the implementation", + }, + ], + }); + const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-diff-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-diff", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-diff-edit-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-edit-file-diff", - toolName: "edit_file", - arguments: { - path: "README.md", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-diff-edit-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-edit-file-diff", - success: true, - result: {}, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-diff-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-diff", - }, - } as SessionEvent); - - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "turn.diff.updated"), - false, - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits an active turn diff when a Copilot Apply_patch tool completes", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-apply-patch-turn-diff"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "edit the docs", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; - - emit({ - id: "evt-copilot-apply-patch-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-apply-patch", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-apply-patch-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-apply-patch", - toolName: "Apply_patch", - arguments: { - patch, - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-apply-patch-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-apply-patch", - success: true, - result: { - content: patch, - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-apply-patch-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-apply-patch", - }, - } as SessionEvent); - - let diffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - NodeAssert.equal(diffEvent.turnId, turn.turnId); - NodeAssert.deepStrictEqual(diffEvent.payload, { - unifiedDiff: patch, - }); - } - - const completedTool = runtimeEvents.find( - (event) => - event.type === "item.completed" && - event.payload.itemType === "file_change" && - String(event.itemId) === "copilot-tool-tool-apply-patch", - ); - NodeAssert.equal(completedTool?.type, "item.completed"); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("classifies terminal apply_patch tool calls as file changes", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-terminal-apply-patch-file-change"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "edit the docs through terminal apply_patch", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; - - emit({ - id: "evt-copilot-terminal-patch-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-terminal-patch", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-terminal-patch-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-terminal-patch", - toolName: "run_in_terminal", - arguments: { - command: `apply_patch <<'PATCH'\n${patch}\nPATCH`, - kind: "execute", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-terminal-patch-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-terminal-patch", - success: true, - result: { - content: `${patch}\n`, - }, - }, - } as SessionEvent); - - let diffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const startedTool = runtimeEvents.find( - (event) => - event.type === "item.started" && - String(event.itemId) === "copilot-tool-tool-terminal-patch", - ); - const completedTool = runtimeEvents.find( - (event) => - event.type === "item.completed" && - String(event.itemId) === "copilot-tool-tool-terminal-patch", - ); - - NodeAssert.equal(startedTool?.type, "item.started"); - if (startedTool?.type === "item.started") { - NodeAssert.equal(startedTool.payload.itemType, "file_change"); - NodeAssert.equal(startedTool.payload.title, "Applied patch"); - NodeAssert.ok( - startedTool.payload.data !== null && - typeof startedTool.payload.data === "object" && - !Array.isArray(startedTool.payload.data), - ); - NodeAssert.equal("command" in startedTool.payload.data, false); - const data = startedTool.payload.data as Record; - NodeAssert.equal(data.kind, "edit"); - } - NodeAssert.equal(completedTool?.type, "item.completed"); - if (completedTool?.type === "item.completed") { - NodeAssert.equal(completedTool.payload.itemType, "file_change"); - NodeAssert.equal(completedTool.payload.title, "Applied patch"); - NodeAssert.ok( - completedTool.payload.data !== null && - typeof completedTool.payload.data === "object" && - !Array.isArray(completedTool.payload.data), - ); - NodeAssert.equal("command" in completedTool.payload.data, false); - const data = completedTool.payload.data as Record; - NodeAssert.equal(data.kind, "edit"); - } - NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - NodeAssert.equal(diffEvent.turnId, turn.turnId); - NodeAssert.equal(diffEvent.payload.unifiedDiff, patch); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits turn diffs when apply-patch output is only in the command", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-terminal-patch-command-only"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "apply a patch", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const patch = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; - - emit({ - id: "evt-copilot-terminal-patch-command-only-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-terminal-patch-command-only" }, - } as SessionEvent); - emit({ - id: "evt-copilot-terminal-patch-command-only-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-terminal-patch-command-only", - toolName: "run_in_terminal", - arguments: { - command: `apply_patch <<'PATCH'\n${patch}\nPATCH`, - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-terminal-patch-command-only-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-terminal-patch-command-only", - success: true, - result: { content: "" }, - }, - } as SessionEvent); - - let diffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - NodeAssert.equal(diffEvent.turnId, turn.turnId); - NodeAssert.equal(diffEvent.payload.unifiedDiff, patch); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect( - "does not emit an active turn diff when a Copilot command tool returns shell control output", - () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-turn-diff"); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - - try { - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - yield* adapter.sendTurn({ - threadId, - input: "run a command", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-command-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-command", - toolName: "bash", - arguments: { - command: "printf done > README.md", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-command", - success: true, - result: { - content: "", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); - - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "turn.diff.updated"), - false, - ); - - const parserErrorCalls = [ - ...consoleErrorSpy.mock.calls, - ...consoleLogSpy.mock.calls, - ].filter((args) => args.some((arg: unknown) => String(arg).includes("parseLineType"))); - NodeAssert.deepStrictEqual(parserErrorCalls, []); - - const completedTool = runtimeEvents.find( - (event) => - event.type === "item.completed" && - event.payload.itemType === "command_execution" && - String(event.itemId) === "copilot-tool-tool-command", - ); - NodeAssert.equal(completedTool?.type, "item.completed"); - - yield* adapter.stopSession(threadId); - } finally { - consoleErrorSpy.mockRestore(); - consoleLogSpy.mockRestore(); - } - }), - ); - - it.effect( - "emits an active turn diff when a Copilot command tool returns git unified diff output", - () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-unified-diff-turn"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run a command", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const diff = [ - "diff --git a/README.md b/README.md", - "index 1111111..2222222 100644", - "--- a/README.md", - "+++ b/README.md", - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"); - - emit({ - id: "evt-copilot-command-diff-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command-diff", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-diff-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-command-diff", - toolName: "bash", - arguments: { - command: "git diff -- README.md", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-diff-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-command-diff", - success: true, - result: { - content: diff, - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-diff-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-command-diff", - }, - } as SessionEvent); - - let diffEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && diffEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - diffEvent = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(diffEvent?.type, "turn.diff.updated"); - if (diffEvent?.type === "turn.diff.updated") { - NodeAssert.equal(diffEvent.turnId, turn.turnId); - NodeAssert.deepStrictEqual(diffEvent.payload, { - unifiedDiff: diff.trim(), - }); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits turn diff when a Copilot write permission is approved", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-write-permission-turn-diff"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "update the README", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const requestId = "permission-write-readme"; - const permissionRequest = { - kind: "write", - toolCallId: "tool-write-readme", - fileName: "README.md", - diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", - intention: "Update README", - canOfferSessionApproval: true, - } as Extract; - - emit({ - id: "evt-copilot-write-permission-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-write-permission", - }, - } as SessionEvent); - - const resultPromise = Promise.resolve( - config.onPermissionRequest(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }), - ); - emit({ - id: "evt-copilot-write-permission-requested", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - promptRequest: undefined, - }, - } as unknown as SessionEvent); - - let opened: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - opened = runtimeEvents.find( - (event) => event.type === "request.opened" && String(event.requestId) === requestId, - ); - } - NodeAssert.equal(opened?.type, "request.opened"); - if (opened?.type === "request.opened") { - NodeAssert.equal(opened.payload.requestType, "file_change_approval"); - NodeAssert.equal(String(opened.turnId), String(turn.turnId)); - } - - yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); - const approvalResult = yield* Effect.promise(() => resultPromise); - NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); - - emit({ - id: "evt-copilot-write-permission-completed", - timestamp, - parentId: null, - type: "permission.completed", - data: { - requestId, - result: approvalResult, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-write-permission-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-write-permission", - }, - } as SessionEvent); - - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); - if (diffUpdated?.type === "turn.diff.updated") { - NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); - NodeAssert.deepStrictEqual(diffUpdated.payload, { - unifiedDiff: permissionRequest.diff.trim(), - }); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("tracks auto-approved Copilot writes through permission completion", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-auto-approved-write-permission-turn-diff"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "auto-accept-edits", - }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "update the README without prompting", - 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 timestamp = yield* nowIso; - const requestId = "permission-auto-approved-write-readme"; - const permissionRequest = { - kind: "write", - toolCallId: "tool-auto-approved-write-readme", - fileName: "README.md", - diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", - intention: "Update README", - canOfferSessionApproval: true, - } as Extract; - - config.onEvent({ - id: "evt-copilot-auto-approved-write-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-auto-approved-write" }, - } as SessionEvent); - const approvalResult = yield* Effect.promise(() => - Promise.resolve( - config.onPermissionRequest?.(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }), - ), - ); - NodeAssert.deepStrictEqual(approvalResult, { kind: "approve-once" }); - config.onEvent({ - id: "evt-copilot-auto-approved-write-requested", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - promptRequest: undefined, - }, - } as unknown as SessionEvent); - config.onEvent({ - id: "evt-copilot-auto-approved-write-completed", - timestamp, - parentId: null, - type: "permission.completed", - data: { - requestId, - result: approvalResult, - }, - } 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( - runtimeEvents.some( - (event) => event.type === "request.opened" && String(event.requestId) === requestId, - ), - false, - ); - const resolved = runtimeEvents.filter( - (event) => event.type === "request.resolved" && String(event.requestId) === requestId, - ); - NodeAssert.equal(resolved.length, 1); - NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); - if (diffUpdated?.type === "turn.diff.updated") { - NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); - NodeAssert.equal(diffUpdated.payload.unifiedDiff, permissionRequest.diff.trim()); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("tracks Copilot writes resolved by an SDK permission hook", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-sdk-hook-approved-write-turn-diff"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "apply the SDK-hook-approved write", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - const requestId = "permission-sdk-hook-approved-write"; - const permissionRequest = { - kind: "write", - toolCallId: "tool-sdk-hook-approved-write", - fileName: "README.md", - diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", - intention: "Update README", - canOfferSessionApproval: true, - } as Extract; - - config.onEvent({ - id: "evt-copilot-sdk-hook-approved-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-hook-approved-write" }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-sdk-hook-approved-write-requested", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - promptRequest: undefined, - resolvedByHook: true, - }, - } as unknown as SessionEvent); - config.onEvent({ - id: "evt-copilot-sdk-hook-approved-write-completed", - timestamp, - parentId: null, - type: "permission.completed", - data: { - requestId, - result: { kind: "approved" }, - }, - } 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( - runtimeEvents.some( - (event) => event.type === "request.opened" && String(event.requestId) === requestId, - ), - false, - ); - NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); - if (diffUpdated?.type === "turn.diff.updated") { - NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); - NodeAssert.equal(diffUpdated.payload.unifiedDiff, permissionRequest.diff.trim()); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits turn diff when Copilot completes write permission with location approval", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-write-permission-location-diff"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "update the README", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - const requestId = "permission-write-readme-location"; - const permissionRequest = { - kind: "write", - toolCallId: "tool-write-readme-location", - fileName: "README.md", - diff: "--- a/README.md\n+++ b/README.md\n@@\n-old\n+new\n", - intention: "Update README", - canOfferSessionApproval: true, - } as Extract; - - emit({ - id: "evt-copilot-write-location-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { turnId: "sdk-turn-write-location" }, - } as SessionEvent); - void config.onPermissionRequest(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }); - emit({ - id: "evt-copilot-write-location-requested", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - promptRequest: undefined, - }, - } as unknown as SessionEvent); - yield* waitForSdkEventQueue(); - - emit({ - id: "evt-copilot-write-location-completed", - timestamp, - parentId: null, - type: "permission.completed", - data: { - requestId, - result: { kind: "approve-for-location" }, - }, - } as unknown as SessionEvent); - - let diffUpdated: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && diffUpdated === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - diffUpdated = runtimeEvents.find((event) => event.type === "turn.diff.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(diffUpdated?.type, "turn.diff.updated"); - if (diffUpdated?.type === "turn.diff.updated") { - NodeAssert.equal(String(diffUpdated.turnId), String(turn.turnId)); - NodeAssert.deepStrictEqual(diffUpdated.payload, { - unifiedDiff: permissionRequest.diff.trim(), - }); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("prompts for shell permissions in auto-accept-edits mode", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-auto-accept-edits-shell-permission"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "auto-accept-edits", - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - NodeAssert.ok(config.onPermissionRequest); - - const permissionRequest: PermissionRequest = { - kind: "shell", - toolCallId: "tool-shell-auto-accept-edits", - fullCommandText: "git status", - intention: "Check repository status", - commands: [{ identifier: "git", readOnly: true }], - possiblePaths: [], - possibleUrls: [], - hasWriteFileRedirection: false, - canOfferSessionApproval: true, - }; - const requestId = "permission-shell-auto-accept-edits"; - const resultPromise = Promise.resolve( - config.onPermissionRequest(permissionRequest, { - sessionId: runtimeMock.state.lastSession.sessionId, - }), - ); - const timestamp = yield* nowIso; - - config.onEvent({ - id: "evt-copilot-permission-auto-accept-edits", - timestamp, - parentId: null, - type: "permission.requested", - data: { - requestId, - permissionRequest, - promptRequest: { - kind: "commands", - toolCallId: "tool-shell-auto-accept-edits", - fullCommandText: "git status", - intention: "Check repository status", - commandIdentifiers: ["git"], - canOfferSessionApproval: true, - }, - }, - } as SessionEvent); - - let opened: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && opened === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - opened = runtimeEvents.find( - (event) => event.type === "request.opened" && String(event.requestId) === requestId, - ); - } - NodeAssert.equal(opened?.type, "request.opened"); - - yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(requestId), "accept"); - const result = yield* Effect.promise(() => resultPromise); - NodeAssert.deepStrictEqual(result, { kind: "approve-once" }); - - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits thread metadata updates from Copilot title changes", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-title-change"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-title-change", - timestamp, - parentId: null, - type: "session.title_changed", - data: { - title: "Implement Copilot thread titles", - }, - } as SessionEvent); - - let titleEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && titleEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - titleEvent = runtimeEvents.find((event) => event.type === "thread.metadata.updated"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(titleEvent?.type, "thread.metadata.updated"); - if (titleEvent?.type === "thread.metadata.updated") { - NodeAssert.equal(titleEvent.threadId, threadId); - NodeAssert.deepStrictEqual(titleEvent.payload, { - name: "Implement Copilot thread titles", - }); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits Copilot background task lifecycle events without 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", - }); - - 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.tasks.list.mockResolvedValueOnce({ - tasks: [ - { - type: "agent", - id: "task-explore-1", - toolCallId: "tool-task-explore-1", - description: "Exploring provider events", - status: "running", - startedAt: "2026-06-11T12:00:00.000Z", - agentType: "explore", - prompt: "Find Copilot task events", - }, - { - type: "shell", - id: "task-shell-1", - description: "Running tests", - status: "completed", - startedAt: "2026-06-11T12:00:01.000Z", - command: "vp test", - attachmentMode: "detached", - }, - { - type: "agent", - id: "task-review-1", - toolCallId: "tool-task-review-1", - description: "Reviewing implementation", - status: "failed", - startedAt: "2026-06-11T12:00:02.000Z", - agentType: "code-review", - prompt: "Review the implementation", - }, - ], - }); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-background-tasks", - timestamp, - parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, - } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && runtimeEvents.filter((event) => event.type === "task.started").length < 3; - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, - ); - const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); - NodeAssert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ - "task-explore-1", - "task-review-1", - "task-shell-1", - ]); - const runningProgress = runtimeEvents.find( - (event) => - event.type === "task.progress" && String(event.payload.taskId) === "task-explore-1", - ); - NodeAssert.equal(runningProgress?.type, "task.progress"); - if (runningProgress?.type === "task.progress") { - NodeAssert.equal(runningProgress.payload.description, "Exploring provider events"); - NodeAssert.equal(runningProgress.payload.summary, "Task running"); - } - const completedTasks = runtimeEvents.filter((event) => event.type === "task.completed"); - NodeAssert.deepStrictEqual( - completedTasks.map((event) => [String(event.payload.taskId), event.payload.status]).sort(), - [ - ["task-review-1", "failed"], - ["task-shell-1", "completed"], - ], - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("tracks Copilot background task status changes", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-background-task-status-changes"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "delegate the investigation", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - - runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ - tasks: [ - { - type: "agent", - id: "task-status-1", - toolCallId: "tool-task-status-1", - description: "Inspect implementation", - status: "running", - startedAt: "2026-06-11T12:00:00.000Z", - agentType: "explore", - prompt: "Inspect implementation", - }, - ], - }); - config.onEvent({ - id: "evt-copilot-background-task-status-running", - timestamp, - parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, - } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "task.progress" && String(event.payload.taskId) === "task-status-1", - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - runtimeMock.state.lastSession.rpc.tasks.list.mockResolvedValueOnce({ - tasks: [ - { - type: "agent", - id: "task-status-1", - toolCallId: "tool-task-status-1", - description: "Inspect implementation", - status: "completed", - startedAt: "2026-06-11T12:00:00.000Z", - completedAt: "2026-06-11T12:00:03.000Z", - agentType: "explore", - prompt: "Inspect implementation", - result: "Inspection completed", - }, - ], - }); - config.onEvent({ - id: "evt-copilot-background-task-status-completed", - timestamp, - parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, - } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const startedEvents = runtimeEvents.filter( - (event) => - event.type === "task.started" && String(event.payload.taskId) === "task-status-1", - ); - const completedEvent = runtimeEvents.find( - (event) => - event.type === "task.completed" && String(event.payload.taskId) === "task-status-1", - ); - NodeAssert.equal(startedEvents.length, 1); - NodeAssert.equal(completedEvent?.type, "task.completed"); - if (completedEvent?.type === "task.completed") { - NodeAssert.equal(completedEvent.turnId, turn.turnId); - NodeAssert.equal(completedEvent.payload.status, "completed"); - NodeAssert.equal(completedEvent.payload.summary, "Inspection completed"); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("does not map Copilot todo tool input to plan updates", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-todo-tool-plan-update"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - yield* adapter.sendTurn({ - threadId, - input: "track todos", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-todo-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-todo-tool", - }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-todo-tool-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-todo-write", - toolName: "TodoWrite", - arguments: { - todos: [ - { content: "Inspect adapter", status: "completed" }, - { content: "Wire task events", status: "in_progress" }, - { content: "Run validation", status: "pending" }, - ], - }, - turnId: "sdk-turn-todo-tool", - }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-todo-drain-marker", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-after-todo-tool", - }, - } as SessionEvent); - - let markerEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - markerEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.reason === "Copilot turn started", - ); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(markerEvent?.type, "session.state.changed"); - NodeAssert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("ignores 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.tasks.list.mockResolvedValueOnce({ - tasks: [], - }); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-empty-background-tasks", - timestamp, - parentId: null, - ephemeral: true, - type: "session.background_tasks_changed", - data: {}, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-empty-background-tasks-drain-marker", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-after-empty-background-tasks", - }, - } as SessionEvent); - - let markerEvent: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && markerEvent === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - markerEvent = runtimeEvents.find( - (event) => - event.type === "session.state.changed" && - event.payload.reason === "Copilot turn started", - ); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(markerEvent?.type, "session.state.changed"); - NodeAssert.equal( - runtimeEvents.find((event) => event.type === "turn.plan.updated"), - undefined, - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits command metadata without unused reasoning or tool output deltas", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-command-metadata"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - yield* adapter.sendTurn({ - threadId, - input: "check git status", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-command-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-command", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-reasoning-1", - timestamp, - parentId: null, - type: "assistant.reasoning_delta", - data: { - reasoningId: "reasoning-command", - deltaContent: "Thinking through ", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-reasoning-2", - timestamp, - parentId: null, - type: "assistant.reasoning_delta", - data: { - reasoningId: "reasoning-command", - deltaContent: "the command", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-command-output-before-start", - timestamp, - parentId: null, - type: "tool.execution_partial_result", - data: { - toolCallId: "tool-command", - partialOutput: "On branch ", - }, - } 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); - emit({ - id: "evt-copilot-command-output-after-start", - timestamp, - parentId: null, - type: "tool.execution_partial_result", - data: { - toolCallId: "tool-command", - partialOutput: "main", - }, - } as SessionEvent); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - emit({ - id: "evt-copilot-command-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-command", - success: true, - result: { - content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", - }, - }, - } as SessionEvent); - - let started: ProviderRuntimeEvent | undefined; - let completed: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - started ??= runtimeEvents.find( - (event) => - event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", - ); - completed = runtimeEvents.find( - (event) => - event.type === "item.completed" && String(event.itemId) === "copilot-tool-tool-command", - ); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - NodeAssert.equal(started?.type, "item.started"); - if (started?.type === "item.started") { - NodeAssert.equal(started.payload.itemType, "command_execution"); - NodeAssert.equal(started.payload.title, "Ran command: git status --short"); - NodeAssert.equal(started.payload.detail, "git status --short"); - } - - NodeAssert.equal(completed?.type, "item.completed"); - if (completed?.type === "item.completed") { - NodeAssert.equal(completed.payload.itemType, "command_execution"); - NodeAssert.equal(completed.payload.title, "Ran command: git status --short"); - NodeAssert.equal( - completed.payload.detail, - "M apps/server/src/provider/Layers/CopilotAdapter.ts", - ); - NodeAssert.deepStrictEqual(completed.payload.data, { - toolCallId: "tool-command", - toolName: "bash", - command: "git status --short", - result: { - content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", - }, - }); - } - - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "content.delta"), - false, - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("ignores empty SDK tool progress messages without failing the session", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-sdk-event-queue-recovers-after-handler-failure"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "finish even after a bad tool progress event", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-turn-start-after-bad-event", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-bad-event", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-bad-progress", - timestamp, - parentId: null, - type: "tool.execution_progress", - data: { - toolCallId: "tool-progress-bad", - progressMessage: null, - }, - } as unknown as SessionEvent); - emit({ - id: "evt-copilot-turn-end-after-bad-event", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-bad-event", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-idle-after-bad-event", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, - }, - } as SessionEvent); - - let completed: ProviderRuntimeEvent | undefined; - for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { - yield* waitForSdkEventQueue(); - completed = runtimeEvents.find((event) => event.type === "turn.completed"); - } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); - const toolProgress = runtimeEvents.find((event) => event.type === "tool.progress"); - NodeAssert.equal(runtimeError, undefined); - NodeAssert.equal(toolProgress, undefined); - NodeAssert.equal(completed?.type, "turn.completed"); - if (completed?.type === "turn.completed") { - NodeAssert.equal(String(completed.turnId), String(turn.turnId)); - NodeAssert.equal(completed.payload.state, "completed"); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("keeps eligible Copilot rate-limit errors non-terminal for automatic fallback", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-eligible-rate-limit"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "continue with an automatic fallback", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-eligible-rate-limit", - timestamp, - parentId: null, - type: "session.error", - data: { - errorType: "rate_limit", - errorCode: "user_weekly_rate_limited", - message: "Weekly premium model limit reached", - eligibleForAutoSwitch: true, - providerCallId: "provider-call-1", - serviceRequestId: "service-request-1", - statusCode: 429, - }, - } as SessionEvent); - yield* waitForSdkEventQueue(); - - const rateLimitEvent = runtimeEvents.find( - (event) => event.type === "account.rate-limits.updated", - ); - NodeAssert.equal(rateLimitEvent?.type, "account.rate-limits.updated"); - if (rateLimitEvent?.type === "account.rate-limits.updated") { - NodeAssert.deepStrictEqual(rateLimitEvent.payload.rateLimits, { - errorType: "rate_limit", - errorCode: "user_weekly_rate_limited", - message: "Weekly premium model limit reached", - eligibleForAutoSwitch: true, - providerCallId: "provider-call-1", - serviceRequestId: "service-request-1", - statusCode: 429, - }); - } - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "runtime.error"), - false, - ); - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ), - false, - ); - const session = (yield* adapter.listSessions()).find( - (entry) => String(entry.threadId) === String(threadId), - ); - NodeAssert.equal(session?.status, "running"); - NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); - - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits and terminally fails non-eligible Copilot quota errors", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-session-error-completes-active-turn"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "trigger a provider session error", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - config.onEvent({ - id: "evt-copilot-session-error-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-session-error", - }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-session-error-message-delta", - timestamp, - parentId: null, - ephemeral: true, - type: "assistant.message_delta", - data: { - messageId: "message-session-error-partial", - deltaContent: "Partial response before provider error.", - }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-session-error-second-message-delta", - timestamp, - parentId: null, - ephemeral: true, - type: "assistant.message_delta", - data: { - messageId: "message-session-error-second-partial", - deltaContent: "Second partial response before provider error.", - }, - } as SessionEvent); - config.onEvent({ - id: "evt-copilot-session-error", - timestamp, - parentId: null, - type: "session.error", - data: { - errorType: "quota", - errorCode: "quota_exceeded", - message: "Copilot quota exceeded", - eligibleForAutoSwitch: false, - statusCode: 429, - }, - } 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"); - const rateLimitEvent = runtimeEvents.find( - (event) => event.type === "account.rate-limits.updated", - ); - NodeAssert.equal(rateLimitEvent?.type, "account.rate-limits.updated"); - if (rateLimitEvent?.type === "account.rate-limits.updated") { - NodeAssert.deepStrictEqual(rateLimitEvent.payload.rateLimits, { - errorType: "quota", - errorCode: "quota_exceeded", - message: "Copilot quota exceeded", - eligibleForAutoSwitch: false, - statusCode: 429, - }); - } - NodeAssert.equal(runtimeError?.type, "runtime.error"); - NodeAssert.equal(completed?.type, "turn.completed"); - if (completed?.type === "turn.completed") { - NodeAssert.equal(completed.payload.state, "failed"); - NodeAssert.equal(completed.payload.errorMessage, "Copilot quota exceeded"); - } - const assistantCompleted = runtimeEvents.filter( - (event) => - event.type === "item.completed" && - (String(event.itemId) === "copilot-message-message-session-error-partial" || - String(event.itemId) === "copilot-message-message-session-error-second-partial"), - ); - NodeAssert.equal(assistantCompleted.length, 2); - for (const completedItem of assistantCompleted) { - NodeAssert.equal(completedItem.type, "item.completed"); - if (completedItem.type === "item.completed") { - NodeAssert.equal(completedItem.payload.status, "failed"); - } - } - const snapshot = (yield* adapter.readThread(threadId)).turns.find( - (entry) => entry.id === turn.turnId, - ); - NodeAssert.ok(snapshot); - NodeAssert.deepStrictEqual(snapshot.items, [ - { - type: "assistant_message", - messageId: "message-session-error-partial", - content: "Partial response before provider error.", - }, - { - type: "assistant_message", - messageId: "message-session-error-second-partial", - content: "Second partial response before provider error.", - }, - ]); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("emits one canonical turn completion for duplicate Copilot lifecycle events", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-duplicate-lifecycle-completion"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "complete once", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-duplicate-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-duplicate-completion", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-duplicate-turn-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-duplicate-completion", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-duplicate-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-duplicate-turn-end-again", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-duplicate-completion", - }, - } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - runtimeEvents.filter((event) => event.type === "turn.completed").length === 0; - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - yield* waitForSdkEventQueue(); - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const completions = runtimeEvents.filter( - (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - NodeAssert.equal(completions.length, 1); - NodeAssert.equal(completions[0]?.type, "turn.completed"); - if (completions[0]?.type === "turn.completed") { - NodeAssert.equal(completions[0].payload.state, "completed"); - } - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("completes a final assistant turn on 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); - emit({ - id: "evt-copilot-final-turn-end-session-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, - }, - } as SessionEvent); - yield* TestClock.adjust("300 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("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; - const threadId = asThreadId("copilot-multi-sdk-loop-before-idle"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "continue through multiple sdk loops", - attachments: [], - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); - const timestamp = yield* nowIso; - - emit({ - id: "evt-copilot-multi-loop-first-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-multi-loop-first", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-multi-loop-first-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, - parentId: null, - type: "assistant.turn_end", - data: { - 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( - runtimeEvents.some((event) => event.type === "turn.completed"), - false, - ); - - emit({ - id: "evt-copilot-multi-loop-second-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-multi-loop-second", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-multi-loop-message", - timestamp, - parentId: null, - type: "assistant.message", - data: { - messageId: "message-multi-loop-second", - content: "Finished after the second loop.", - turnId: "sdk-turn-multi-loop-second", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-multi-loop-second-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-multi-loop-second", - }, - } as SessionEvent); - yield* TestClock.adjust("25 millis"); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - emit({ - id: "evt-copilot-multi-loop-idle", + config.onEvent({ + id: "evt-copilot-background-tasks", timestamp, parentId: null, - type: "session.idle", - data: { - aborted: false, - }, + ephemeral: true, + type: "session.background_tasks_changed", + data: {}, } as SessionEvent); - yield* waitForSdkEventQueue(); + for ( + let attempt = 0; + attempt < 20 && runtimeEvents.filter((event) => event.type === "task.started").length < 3; + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const messageCompleted = runtimeEvents.find( + NodeAssert.equal( + runtimeEvents.find((event) => event.type === "turn.plan.updated"), + undefined, + ); + const startedTasks = runtimeEvents.filter((event) => event.type === "task.started"); + NodeAssert.deepStrictEqual(startedTasks.map((event) => String(event.payload.taskId)).sort(), [ + "task-explore-1", + "task-review-1", + "task-shell-1", + ]); + const runningProgress = runtimeEvents.find( (event) => - event.type === "item.completed" && - event.itemId === "copilot-message-message-multi-loop-second", + event.type === "task.progress" && String(event.payload.taskId) === "task-explore-1", ); - const completions = runtimeEvents.filter( - (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + NodeAssert.equal(runningProgress?.type, "task.progress"); + if (runningProgress?.type === "task.progress") { + NodeAssert.equal(runningProgress.payload.description, "Exploring provider events"); + NodeAssert.equal(runningProgress.payload.summary, "Task running"); + } + const completedTasks = runtimeEvents.filter((event) => event.type === "task.completed"); + NodeAssert.deepStrictEqual( + completedTasks.map((event) => [String(event.payload.taskId), event.payload.status]).sort(), + [ + ["task-review-1", "failed"], + ["task-shell-1", "completed"], + ], ); - NodeAssert.equal(messageCompleted?.type, "item.completed"); - NodeAssert.equal(String(messageCompleted?.turnId), String(turn.turnId)); - NodeAssert.equal(completions.length, 1); yield* adapter.stopSession(threadId); }), ); - it.effect("keeps a Copilot turn running while a subagent executes", () => + it.effect("emits command metadata without unused reasoning or tool output deltas", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-subagent-running-lifecycle"); + const threadId = asThreadId("copilot-command-metadata"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -5628,9 +2261,9 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ + yield* adapter.sendTurn({ threadId, - input: "delegate this work to a subagent", + input: "check git status", attachments: [], }); @@ -5647,259 +2280,145 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const timestamp = yield* nowIso; emit({ - id: "evt-copilot-subagent-root-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-subagent-root", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-root-message", - timestamp, - parentId: null, - type: "assistant.message", - data: { - messageId: "message-subagent-root", - content: "I am delegating this work.", - turnId: "sdk-turn-subagent-root", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-root-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-subagent-root", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-tool-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-subagent", - toolName: "Task", - turnId: "sdk-turn-subagent-root", - arguments: { - description: "Investigate the delegated work", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-started", - timestamp, - parentId: null, - type: "subagent.started", - agentId: "subagent-1", - data: { - agentDescription: "Investigates delegated work", - agentDisplayName: "Research agent", - agentName: "research", - toolCallId: "tool-subagent", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-turn-start", + id: "evt-copilot-command-turn-start", timestamp, parentId: null, type: "assistant.turn_start", - agentId: "subagent-1", data: { - turnId: "sdk-turn-subagent", + turnId: "sdk-turn-command", }, } as SessionEvent); emit({ - id: "evt-copilot-subagent-message", + id: "evt-copilot-command-reasoning-1", timestamp, parentId: null, - type: "assistant.message", - agentId: "subagent-1", + type: "assistant.reasoning_delta", data: { - messageId: "message-subagent", - content: "The delegated investigation is still running.", - parentToolCallId: "tool-subagent", - turnId: "sdk-turn-subagent", + reasoningId: "reasoning-command", + deltaContent: "Thinking through ", }, } as SessionEvent); emit({ - id: "evt-copilot-subagent-turn-end", + id: "evt-copilot-command-reasoning-2", timestamp, parentId: null, - type: "assistant.turn_end", - agentId: "subagent-1", + type: "assistant.reasoning_delta", data: { - turnId: "sdk-turn-subagent", + reasoningId: "reasoning-command", + deltaContent: "the command", }, } as SessionEvent); - - yield* TestClock.adjust("25 millis"); - yield* waitForSdkEventQueue(); - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "turn.completed"), - false, - ); - emit({ - id: "evt-copilot-subagent-completed", + id: "evt-copilot-command-output-before-start", timestamp, parentId: null, - type: "subagent.completed", - agentId: "subagent-1", + type: "tool.execution_partial_result", data: { - agentDisplayName: "Research agent", - agentName: "research", - toolCallId: "tool-subagent", + toolCallId: "tool-command", + partialOutput: "On branch ", }, } as SessionEvent); emit({ - id: "evt-copilot-subagent-tool-complete", + id: "evt-copilot-command-start", timestamp, parentId: null, - type: "tool.execution_complete", + type: "tool.execution_start", data: { - toolCallId: "tool-subagent", - turnId: "sdk-turn-subagent-root", - success: true, - result: { - content: "Delegated investigation completed.", + toolCallId: "tool-command", + toolName: "bash", + arguments: { + command: "git status --short", }, }, } as SessionEvent); emit({ - id: "evt-copilot-subagent-intermediate-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, - }, - } as SessionEvent); - - yield* TestClock.adjust("300 millis"); - yield* waitForSdkEventQueue(); - NodeAssert.equal( - runtimeEvents.some((event) => event.type === "turn.completed"), - false, - ); - - emit({ - id: "evt-copilot-subagent-final-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-subagent-final", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-final-message", - timestamp, - parentId: null, - type: "assistant.message", - data: { - messageId: "message-subagent-final", - content: "The delegated work is complete.", - turnId: "sdk-turn-subagent-final", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-final-end", - timestamp, - parentId: null, - type: "assistant.turn_end", - data: { - turnId: "sdk-turn-subagent-final", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-subagent-final-idle", + id: "evt-copilot-command-output-after-start", timestamp, parentId: null, - type: "session.idle", + type: "tool.execution_partial_result", data: { - aborted: false, + toolCallId: "tool-command", + partialOutput: "main", }, } as SessionEvent); - - yield* TestClock.adjust("300 millis"); for ( let attempt = 0; attempt < 20 && !runtimeEvents.some( (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", ); 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); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("ignores unmapped sdk turn starts without synthesizing a turn id", () => - Effect.gen(function* () { - const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-unmapped-sdk-turn-start"); - - yield* adapter.startSession({ - provider: COPILOT_DRIVER, - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), - Effect.forkChild, - ); - yield* waitForSdkEventQueue(); - - const config = runtimeMock.state.createSessionConfigs.at(-1); - NodeAssert.ok(config?.onEvent); - const timestamp = yield* nowIso; - - config.onEvent({ - id: "evt-copilot-unmapped-turn-start", + emit({ + id: "evt-copilot-command-complete", timestamp, parentId: null, - type: "assistant.turn_start", + type: "tool.execution_complete", data: { - turnId: "sdk-turn-unmapped", + toolCallId: "tool-command", + success: true, + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, }, } as SessionEvent); - yield* waitForSdkEventQueue(); - + let started: ProviderRuntimeEvent | undefined; + let completed: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && completed === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + started ??= runtimeEvents.find( + (event) => + event.type === "item.started" && String(event.itemId) === "copilot-tool-tool-command", + ); + completed = runtimeEvents.find( + (event) => + event.type === "item.completed" && String(event.itemId) === "copilot-tool-tool-command", + ); + } yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const runtimeWarning = runtimeEvents.find((event) => event.type === "runtime.warning"); - const runningState = runtimeEvents.find( - (event) => event.type === "session.state.changed" && event.payload.state === "running", + NodeAssert.equal(started?.type, "item.started"); + if (started?.type === "item.started") { + NodeAssert.equal(started.payload.itemType, "command_execution"); + NodeAssert.equal(started.payload.title, "Ran command: git status --short"); + NodeAssert.equal(started.payload.detail, "git status --short"); + } + + NodeAssert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + NodeAssert.equal(completed.payload.itemType, "command_execution"); + NodeAssert.equal(completed.payload.title, "Ran command: git status --short"); + NodeAssert.equal( + completed.payload.detail, + "M apps/server/src/provider/Layers/CopilotAdapter.ts", + ); + NodeAssert.deepStrictEqual(completed.payload.data, { + toolCallId: "tool-command", + toolName: "bash", + command: "git status --short", + result: { + content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", + }, + }); + } + + NodeAssert.equal( + runtimeEvents.some((event) => event.type === "content.delta"), + false, ); - NodeAssert.equal(runtimeWarning, undefined); - NodeAssert.equal(runningState, undefined); yield* adapter.stopSession(threadId); }), ); - it.effect("does not remap an unmapped sdk turn_start to the latest completed turn", () => + it.effect("emits and terminally fails non-eligible Copilot quota errors", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-unmapped-sdk-turn-start-after-completion"); + const threadId = asThreadId("copilot-session-error-completes-active-turn"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -5908,6 +2427,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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))), @@ -5917,96 +2442,120 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const config = runtimeMock.state.createSessionConfigs.at(-1); NodeAssert.ok(config?.onEvent); - const emit = (event: SessionEvent) => config.onEvent?.(event); const timestamp = yield* nowIso; - - const firstTurn = yield* adapter.sendTurn({ - threadId, - input: "first prompt", - attachments: [], - }); - - emit({ - id: "evt-copilot-unmapped-after-complete-first-start", + config.onEvent({ + id: "evt-copilot-session-error-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-unmapped-after-complete-first", + turnId: "sdk-turn-session-error", }, } as SessionEvent); - emit({ - id: "evt-copilot-unmapped-after-complete-first-end", + config.onEvent({ + id: "evt-copilot-session-error-message-delta", timestamp, parentId: null, - type: "assistant.turn_end", + ephemeral: true, + type: "assistant.message_delta", data: { - turnId: "sdk-turn-unmapped-after-complete-first", + messageId: "message-session-error-partial", + deltaContent: "Partial response before provider error.", }, } as SessionEvent); - emit({ - id: "evt-copilot-unmapped-after-complete-first-idle", + config.onEvent({ + id: "evt-copilot-session-error-second-message-delta", timestamp, parentId: null, - type: "session.idle", + ephemeral: true, + type: "assistant.message_delta", data: { - aborted: false, + messageId: "message-session-error-second-partial", + deltaContent: "Second partial response before provider error.", }, } 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", + config.onEvent({ + id: "evt-copilot-session-error", timestamp, parentId: null, - type: "assistant.turn_start", + type: "session.error", data: { - turnId: "sdk-turn-unmapped-after-complete-second", + errorType: "quota", + errorCode: "quota_exceeded", + message: "Copilot quota exceeded", + eligibleForAutoSwitch: false, + statusCode: 429, }, } as SessionEvent); - yield* waitForSdkEventQueue(); - + 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 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 runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + const rateLimitEvent = runtimeEvents.find( + (event) => event.type === "account.rate-limits.updated", ); - const runtimeWarning = postSecondStartEvents.find( + NodeAssert.equal(rateLimitEvent?.type, "account.rate-limits.updated"); + if (rateLimitEvent?.type === "account.rate-limits.updated") { + NodeAssert.deepStrictEqual(rateLimitEvent.payload.rateLimits, { + errorType: "quota", + errorCode: "quota_exceeded", + message: "Copilot quota exceeded", + eligibleForAutoSwitch: false, + statusCode: 429, + }); + } + NodeAssert.equal(runtimeError?.type, "runtime.error"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(completed.payload.state, "failed"); + NodeAssert.equal(completed.payload.errorMessage, "Copilot quota exceeded"); + } + const assistantCompleted = runtimeEvents.filter( (event) => - event.type === "runtime.warning" && - event.payload.message.includes("sdk-turn-unmapped-after-complete-second"), + event.type === "item.completed" && + (String(event.itemId) === "copilot-message-message-session-error-partial" || + String(event.itemId) === "copilot-message-message-session-error-second-partial"), ); - - NodeAssert.equal(runtimeWarning, undefined); - NodeAssert.equal(staleRunningState, undefined); + NodeAssert.equal(assistantCompleted.length, 2); + for (const completedItem of assistantCompleted) { + NodeAssert.equal(completedItem.type, "item.completed"); + if (completedItem.type === "item.completed") { + NodeAssert.equal(completedItem.payload.status, "failed"); + } + } + const snapshot = (yield* adapter.readThread(threadId)).turns.find( + (entry) => entry.id === turn.turnId, + ); + NodeAssert.ok(snapshot); + NodeAssert.deepStrictEqual(snapshot.items, [ + { + type: "assistant_message", + messageId: "message-session-error-partial", + content: "Partial response before provider error.", + }, + { + type: "assistant_message", + messageId: "message-session-error-second-partial", + content: "Second partial response before provider error.", + }, + ]); yield* adapter.stopSession(threadId); }), ); - it.effect("does not complete a queued turn from the previous Copilot idle event", () => + it.effect("emits one canonical turn completion for duplicate Copilot lifecycle events", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-queued-turn-idle-correlation"); + const threadId = asThreadId("copilot-duplicate-lifecycle-completion"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -6015,6 +2564,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { 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))), @@ -6027,66 +2582,26 @@ 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-duplicate-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-queued-first", + turnId: "sdk-turn-duplicate-completion", }, } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "session.state.changed" && - String(event.turnId) === String(firstTurn.turnId) && - event.payload.state === "running", - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "session.state.changed" && - String(event.turnId) === String(firstTurn.turnId) && - event.payload.state === "running", - ), - true, - ); - - const secondTurn = yield* adapter.sendTurn({ - threadId, - input: "second prompt", - attachments: [], - }); - NodeAssert.deepStrictEqual( - runtimeEvents - .filter((event) => event.type === "turn.started") - .map((event) => String(event.turnId)), - [String(firstTurn.turnId)], - ); emit({ - id: "evt-copilot-queued-first-turn-end", + id: "evt-copilot-duplicate-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-queued-first", + turnId: "sdk-turn-duplicate-completion", }, } as SessionEvent); emit({ - id: "evt-copilot-queued-first-idle", + id: "evt-copilot-duplicate-idle", timestamp, parentId: null, type: "session.idle", @@ -6094,111 +2609,44 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - yield* waitForSdkEventQueue(); - - const completionsAfterFirstIdle = runtimeEvents.filter( - (event) => event.type === "turn.completed", - ); - NodeAssert.equal( - completionsAfterFirstIdle.filter( - (event) => String(event.turnId) === String(firstTurn.turnId), - ).length, - 1, - ); - NodeAssert.equal( - completionsAfterFirstIdle.filter( - (event) => String(event.turnId) === String(secondTurn.turnId), - ).length, - 0, - ); - const latestEventAfterFirstIdle = runtimeEvents.at(-1); - NodeAssert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); - NodeAssert.equal(latestEventAfterFirstIdle.payload.state, "running"); - - emit({ - id: "evt-copilot-queued-second-turn-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-queued-second", - }, - } as SessionEvent); - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.started" && String(event.turnId) === String(secondTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - NodeAssert.deepStrictEqual( - runtimeEvents - .filter((event) => event.type === "turn.started") - .map((event) => String(event.turnId)), - [String(firstTurn.turnId), String(secondTurn.turnId)], - ); emit({ - id: "evt-copilot-queued-second-turn-end", + id: "evt-copilot-duplicate-turn-end-again", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-queued-second", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-queued-second-idle", - timestamp, - parentId: null, - type: "session.idle", - data: { - aborted: false, + turnId: "sdk-turn-duplicate-completion", }, } as SessionEvent); for ( - let attempt = 0; - attempt < 20 && - runtimeEvents.filter( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), - ).length === 0; + 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 secondCompletions = runtimeEvents.filter( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - NodeAssert.equal(secondCompletions.length, 1); + 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("waits for idle before completing a successful tool turn with a queued follow-up", () => + 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-session-idle-completion"); + const threadId = asThreadId("copilot-multi-sdk-loop-before-idle"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -6209,7 +2657,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const turn = yield* adapter.sendTurn({ threadId, - input: "finish on assistant idle", + input: "continue through multiple sdk loops", attachments: [], }); @@ -6226,77 +2674,80 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { const timestamp = yield* nowIso; emit({ - id: "evt-copilot-transient-idle-start", + id: "evt-copilot-multi-loop-first-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-transient-idle", + turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); emit({ - id: "evt-copilot-transient-idle-turn-end", + id: "evt-copilot-multi-loop-first-message", timestamp, parentId: null, - type: "assistant.turn_end", + type: "assistant.message", data: { - turnId: "sdk-turn-transient-idle", + messageId: "message-multi-loop-first", + content: "First loop partial output.", + turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); emit({ - id: "evt-copilot-successful-tool-start", + id: "evt-copilot-multi-loop-first-end", timestamp, parentId: null, - type: "tool.execution_start", + type: "assistant.turn_end", data: { - toolCallId: "tool-successful-idle", - toolName: "shell", - turnId: "sdk-turn-transient-idle", - arguments: { - command: "true", - }, + turnId: "sdk-turn-multi-loop-first", }, } as SessionEvent); emit({ - id: "evt-copilot-successful-tool-complete", + id: "evt-copilot-multi-loop-idle-between-loops", timestamp, parentId: null, - type: "tool.execution_complete", + type: "session.idle", data: { - toolCallId: "tool-successful-idle", - turnId: "sdk-turn-transient-idle", - success: true, - result: { - content: "Command completed", - }, + aborted: false, }, } as SessionEvent); - yield* waitForSdkEventQueue(); - const queuedTurn = yield* adapter.sendTurn({ - threadId, - input: "queue this until the successful tool turn is idle", - attachments: [], - }); + yield* waitForSdkEventQueue(); NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ), + runtimeEvents.some((event) => event.type === "turn.completed"), false, ); emit({ - id: "evt-copilot-session-idle", + id: "evt-copilot-multi-loop-second-start", timestamp, parentId: null, - type: "session.idle", + type: "assistant.turn_start", data: { - aborted: false, + turnId: "sdk-turn-multi-loop-second", }, } as SessionEvent); - yield* waitForSdkEventQueue(); - yield* TestClock.adjust("300 millis"); + emit({ + id: "evt-copilot-multi-loop-message", + timestamp, + parentId: null, + type: "assistant.message", + data: { + messageId: "message-multi-loop-second", + content: "Finished after the second loop.", + turnId: "sdk-turn-multi-loop-second", + }, + } as SessionEvent); + emit({ + id: "evt-copilot-multi-loop-second-end", + timestamp, + parentId: null, + type: "assistant.turn_end", + data: { + turnId: "sdk-turn-multi-loop-second", + }, + } as SessionEvent); + yield* TestClock.adjust("25 millis"); for ( let attempt = 0; @@ -6310,20 +2761,8 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { yield* waitForSdkEventQueue(); } - const completions = runtimeEvents.filter( - (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); - NodeAssert.equal(completions.length, 1); - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(queuedTurn.turnId), - ), - false, - ); - emit({ - id: "evt-copilot-session-idle-duplicate", + id: "evt-copilot-multi-loop-idle", timestamp, parentId: null, type: "session.idle", @@ -6331,29 +2770,30 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + yield* waitForSdkEventQueue(); yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const completionsAfterDuplicateIdle = runtimeEvents.filter( - (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + const messageCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.itemId === "copilot-message-message-multi-loop-second", ); - NodeAssert.equal(completionsAfterDuplicateIdle.length, 1); - NodeAssert.equal( - runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(queuedTurn.turnId), - ), - false, + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); + NodeAssert.equal(messageCompleted?.type, "item.completed"); + NodeAssert.equal(String(messageCompleted?.turnId), String(turn.turnId)); + NodeAssert.equal(completions.length, 1); yield* adapter.stopSession(threadId); }), ); - it.effect("does not complete a successful tool turn when the assistant loop continues", () => + 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-successful-tool-continuation"); + const threadId = asThreadId("copilot-queued-turn-idle-correlation"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -6362,12 +2802,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "continue after a successful tool", - attachments: [], - }); - const runtimeEvents: ProviderRuntimeEvent[] = []; const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), @@ -6380,45 +2814,66 @@ 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-tool-loop-start", + id: "evt-copilot-queued-first-turn-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-tool-loop", - }, - } as SessionEvent); - emit({ - id: "evt-copilot-tool-loop-tool-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-loop-success", - toolName: "shell", - turnId: "sdk-turn-tool-loop", - arguments: { - command: "true", - }, + turnId: "sdk-turn-queued-first", }, } as SessionEvent); + + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "session.state.changed" && + String(event.turnId) === String(firstTurn.turnId) && + event.payload.state === "running", + ), + true, + ); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + }); + NodeAssert.deepStrictEqual( + runtimeEvents + .filter((event) => event.type === "turn.started") + .map((event) => String(event.turnId)), + [String(firstTurn.turnId)], + ); emit({ - id: "evt-copilot-tool-loop-tool-complete", + id: "evt-copilot-queued-first-turn-end", timestamp, parentId: null, - type: "tool.execution_complete", + type: "assistant.turn_end", data: { - toolCallId: "tool-loop-success", - turnId: "sdk-turn-tool-loop", - success: true, - result: { - content: "Command completed", - }, + turnId: "sdk-turn-queued-first", }, } as SessionEvent); emit({ - id: "evt-copilot-tool-loop-transient-idle", + id: "evt-copilot-queued-first-idle", timestamp, parentId: null, type: "session.idle", @@ -6426,50 +2881,76 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); - yield* waitForSdkEventQueue(); - emit({ - id: "evt-copilot-tool-loop-continuation-start", - timestamp, - parentId: null, - type: "assistant.turn_start", - data: { - turnId: "sdk-turn-tool-loop-continuation", - }, - } 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(); - yield* TestClock.adjust("300 millis"); + + const completionsAfterFirstIdle = runtimeEvents.filter( + (event) => event.type === "turn.completed", + ); + NodeAssert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(firstTurn.turnId), + ).length, + 1, + ); + NodeAssert.equal( + completionsAfterFirstIdle.filter( + (event) => String(event.turnId) === String(secondTurn.turnId), + ).length, + 0, + ); + const latestEventAfterFirstIdle = runtimeEvents.at(-1); + NodeAssert.equal(latestEventAfterFirstIdle?.type, "session.state.changed"); + NodeAssert.equal(latestEventAfterFirstIdle.payload.state, "running"); emit({ - id: "evt-copilot-tool-loop-stale-idle", + id: "evt-copilot-queued-second-turn-start", timestamp, parentId: null, - type: "session.idle", + type: "assistant.turn_start", data: { - aborted: false, + turnId: "sdk-turn-queued-second", }, } as SessionEvent); - yield* waitForSdkEventQueue(); - - NodeAssert.equal( - runtimeEvents.some( + for ( + let attempt = 0; + attempt < 20 && + !runtimeEvents.some( (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ), - false, + event.type === "turn.started" && String(event.turnId) === String(secondTurn.turnId), + ); + attempt += 1 + ) { + yield* waitForSdkEventQueue(); + } + NodeAssert.deepStrictEqual( + runtimeEvents + .filter((event) => event.type === "turn.started") + .map((event) => String(event.turnId)), + [String(firstTurn.turnId), String(secondTurn.turnId)], ); - emit({ - id: "evt-copilot-tool-loop-continuation-end", + id: "evt-copilot-queued-second-turn-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-tool-loop-continuation", + turnId: "sdk-turn-queued-second", }, } as SessionEvent); emit({ - id: "evt-copilot-tool-loop-final-idle", + id: "evt-copilot-queued-second-idle", timestamp, parentId: null, type: "session.idle", @@ -6477,32 +2958,34 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + for ( let attempt = 0; attempt < 20 && - !runtimeEvents.some( + runtimeEvents.filter( (event) => - event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), - ); + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + ).length === 0; 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), + const secondCompletions = runtimeEvents.filter( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), ); - NodeAssert.equal(completions.length, 1); + NodeAssert.equal(secondCompletions.length, 1); yield* adapter.stopSession(threadId); }), ); - it.effect("does not let stale sdk turn_start steal the next queued turn id", () => + it.effect("does not complete a successful tool turn when the assistant loop continues", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; - const threadId = asThreadId("copilot-stale-turn-start-does-not-steal-queue"); + const threadId = asThreadId("copilot-successful-tool-continuation"); yield* adapter.startSession({ provider: COPILOT_DRIVER, @@ -6511,6 +2994,12 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { runtimeMode: "approval-required", }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "continue after a successful tool", + attachments: [], + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), @@ -6523,49 +3012,45 @@ 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: [], - }); - const secondTurn = yield* adapter.sendTurn({ - threadId, - input: "second prompt", - attachments: [], - }); - emit({ - id: "evt-copilot-stale-steal-first-start", + id: "evt-copilot-tool-loop-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-first", + turnId: "sdk-turn-tool-loop", }, } as SessionEvent); emit({ - id: "evt-copilot-stale-steal-stale-start", + id: "evt-copilot-tool-loop-tool-start", timestamp, parentId: null, - type: "assistant.turn_start", + type: "tool.execution_start", data: { - turnId: "sdk-turn-stale", + toolCallId: "tool-loop-success", + toolName: "shell", + turnId: "sdk-turn-tool-loop", + arguments: { + command: "true", + }, }, } as SessionEvent); - - yield* waitForSdkEventQueue(); - emit({ - id: "evt-copilot-stale-steal-first-end", + id: "evt-copilot-tool-loop-tool-complete", timestamp, parentId: null, - type: "assistant.turn_end", + type: "tool.execution_complete", data: { - turnId: "sdk-turn-first", + toolCallId: "tool-loop-success", + turnId: "sdk-turn-tool-loop", + success: true, + result: { + content: "Command completed", + }, }, } as SessionEvent); emit({ - id: "evt-copilot-stale-steal-first-idle", + id: "evt-copilot-tool-loop-transient-idle", timestamp, parentId: null, type: "session.idle", @@ -6573,26 +3058,50 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); + yield* waitForSdkEventQueue(); + emit({ - id: "evt-copilot-stale-steal-second-start", + id: "evt-copilot-tool-loop-continuation-start", timestamp, parentId: null, type: "assistant.turn_start", data: { - turnId: "sdk-turn-second", + turnId: "sdk-turn-tool-loop-continuation", + }, + } as SessionEvent); + yield* waitForSdkEventQueue(); + yield* TestClock.adjust("300 millis"); + + emit({ + id: "evt-copilot-tool-loop-stale-idle", + timestamp, + parentId: null, + type: "session.idle", + data: { + aborted: false, }, } as SessionEvent); + yield* waitForSdkEventQueue(); + + NodeAssert.equal( + runtimeEvents.some( + (event) => + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), + ), + false, + ); + emit({ - id: "evt-copilot-stale-steal-second-end", + id: "evt-copilot-tool-loop-continuation-end", timestamp, parentId: null, type: "assistant.turn_end", data: { - turnId: "sdk-turn-second", + turnId: "sdk-turn-tool-loop-continuation", }, } as SessionEvent); emit({ - id: "evt-copilot-stale-steal-second-idle", + id: "evt-copilot-tool-loop-final-idle", timestamp, parentId: null, type: "session.idle", @@ -6600,37 +3109,23 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { aborted: false, }, } as SessionEvent); - for ( let attempt = 0; attempt < 20 && !runtimeEvents.some( (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); attempt += 1 ) { yield* waitForSdkEventQueue(); } - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - const staleWarning = runtimeEvents.find( - (event) => - event.type === "runtime.warning" && event.payload.message.includes("sdk-turn-stale"), - ); - NodeAssert.equal(staleWarning, undefined); - - const firstCompletion = runtimeEvents.find( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), - ); - const secondCompletion = runtimeEvents.find( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), + const completions = runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.turnId) === String(turn.turnId), ); - NodeAssert.equal(firstCompletion?.type, "turn.completed"); - NodeAssert.equal(secondCompletion?.type, "turn.completed"); + NodeAssert.equal(completions.length, 1); yield* adapter.stopSession(threadId); }), @@ -6734,178 +3229,6 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); - it.effect("maps the next turn after a failed tool completes the first turn on idle", () => - 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-message", - timestamp, - parentId: null, - type: "assistant.message", - data: { - messageId: "message-idle-only-first", - content: "I will try a tool.", - 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-failed-tool-start", - timestamp, - parentId: null, - type: "tool.execution_start", - data: { - toolCallId: "tool-idle-only-failure", - toolName: "shell", - turnId: "sdk-turn-idle-only-first", - arguments: { - command: "false", - }, - }, - } as SessionEvent); - emit({ - id: "evt-copilot-idle-only-failed-tool-complete", - timestamp, - parentId: null, - type: "tool.execution_complete", - data: { - toolCallId: "tool-idle-only-failure", - turnId: "sdk-turn-idle-only-first", - success: false, - error: { - message: "Command failed", - }, - }, - } 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"); - - 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); - yield* TestClock.adjust("25 millis"); - - for ( - let attempt = 0; - attempt < 20 && - !runtimeEvents.some( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), - ); - attempt += 1 - ) { - yield* waitForSdkEventQueue(); - } - - yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); - - const firstTurnCompletions = runtimeEvents.filter( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(firstTurn.turnId), - ); - const secondTurnCompletions = runtimeEvents.filter( - (event) => - event.type === "turn.completed" && String(event.turnId) === String(secondTurn.turnId), - ); - NodeAssert.equal(firstTurnCompletions.length, 1); - NodeAssert.equal(secondTurnCompletions.length, 1); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("drains queued SDK events before disconnecting on stop", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 8274d70e05b5..8b8866652660 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -752,10 +752,6 @@ function completedCopilotTaskStatus( } } -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function copilotTaskId(task: CopilotTaskInfo): string | undefined { return task.id; } @@ -2383,23 +2379,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( await emitSessionIdleStateChanged(context, event); return; } - case "session.title_changed": { - const title = trimOrUndefined(event.data.title); - if (!title) { - return; - } - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - raw: event, - }), - type: "thread.metadata.updated", - payload: { - name: title, - }, - }); - return; - } case "session.warning": { await emitAsync({ ...createBaseEvent({ diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index ebf55ea3f57d..491f718a9776 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -2,7 +2,6 @@ import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeFSP from "node:fs/promises"; -import * as NodeTimersPromises from "node:timers/promises"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -117,7 +116,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { function waitForJsonLogMatch( filePath: string, predicate: (entry: Record) => boolean, - attempts = 100, + attempts = 40, ) { return Effect.gen(function* () { for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -125,7 +124,7 @@ function waitForJsonLogMatch( if (requests.some(predicate)) { return requests; } - yield* Effect.promise(() => NodeTimersPromises.setTimeout(10)); + yield* Effect.yieldNow; } return yield* Effect.promise(() => readJsonLines(filePath)); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 910f69c5e2a0..0fd88b4262a6 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -118,11 +118,7 @@ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; - // Acquire the PubSub subscription in this fiber before forking the - // consumer. `streamChanges` subscribes only when the forked stream starts, - // which leaves a window where an immediate settings publish is dropped. - const settingsChanges = yield* serverSettings.subscribeChanges; - yield* settingsChanges.pipe( + yield* serverSettings.streamChanges.pipe( Stream.runForEach((next) => mutator .reconcile(deriveProviderInstanceConfigMap(next)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 312551dfb987..1a39d01176ca 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,6 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -280,9 +279,8 @@ function makeMutableServerSettingsService( return Effect.gen(function* () { const settingsRef = yield* Ref.make(initial); const changes = yield* PubSub.unbounded(); - const settingsWatcherSubscribed = yield* Deferred.make(); - const service = { + return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), @@ -298,17 +296,7 @@ function makeMutableServerSettingsService( get streamChanges() { return Stream.fromPubSub(changes); }, - get subscribeChanges() { - return PubSub.subscribe(changes).pipe( - Effect.tap(() => Deferred.succeed(settingsWatcherSubscribed, undefined)), - Effect.map(Stream.fromSubscription), - ); - }, } satisfies ServerSettingsModule.ServerSettingsService["Service"]; - - return Object.assign(service, { - settingsWatcherSubscribed: Deferred.await(settingsWatcherSubscribed), - }); }); } @@ -1240,7 +1228,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - yield* serverSettings.settingsWatcherSubscribed; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 32251e45f428..504d99e18def 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -298,32 +298,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("falls back to an enabled custom instance before legacy providers", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const fallbackInstanceId = ProviderInstanceId.make("copilot_personal"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [fallbackInstanceId]: { - driver: ProviderDriverKind.make("copilot"), - enabled: true, - config: {}, - }, - }, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("copilot_deleted"), - model: "gpt-4.1", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId: fallbackInstanceId, - model: "gpt-4.1", - }); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index def26775e128..4119a72640fe 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -127,14 +127,6 @@ export class ServerSettingsService extends Context.Service< /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; - - /** - * Acquire the settings change subscription synchronously and return a - * stream backed by it. Consumers that fork a long-running watcher should - * use this instead of `streamChanges` so a publish cannot land between - * scheduling the watcher fiber and the fiber subscribing. - */ - readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { /** @deprecated Import and use `layerTest` from this module. */ @@ -164,7 +156,6 @@ const makeTest = (overrides: DeepPartial = {}) => Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, - subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; }); @@ -206,24 +197,6 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackInstanceEntry = Object.entries(settings.providerInstances).find( - ([, instance]) => instance.enabled ?? true, - ); - if (fallbackInstanceEntry) { - const [instanceId, instance] = fallbackInstanceEntry; - const driver = instance.driver; - return { - ...settings, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(instanceId), - model: - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_GIT_TEXT_GENERATION_MODEL, - } satisfies ModelSelection, - }; - } - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { @@ -583,23 +556,6 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); - const resolveChangedSettings = (changes: Stream.Stream) => - changes.pipe( - Stream.mapEffect((settings) => - materializeProviderEnvironmentSecrets(settings).pipe( - Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }).pipe(Effect.as(settings)), - ), - ), - ), - Stream.map(resolveTextGenerationProvider), - ); - return { start, ready: Deferred.await(startedDeferred), @@ -624,11 +580,20 @@ const make = Effect.gen(function* () { }), ), get streamChanges() { - return resolveChangedSettings(Stream.fromPubSub(changesPubSub)); - }, - get subscribeChanges() { - return PubSub.subscribe(changesPubSub).pipe( - Effect.map((subscription) => resolveChangedSettings(Stream.fromSubscription(subscription))), + return Stream.fromPubSub(changesPubSub).pipe( + Stream.mapEffect((settings) => + materializeProviderEnvironmentSecrets(settings).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(settings)), + ), + ), + ), + Stream.map(resolveTextGenerationProvider), ); }, } satisfies ServerSettingsService["Service"]; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 837f96558c88..c296c717066a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1861,10 +1861,7 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, selectedProvider), - [selectedProvider, threadActivities], - ); + const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -1926,13 +1923,8 @@ function ChatViewContent(props: ChatViewProps) { [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( - () => - deriveActivePlanState( - threadActivities, - activeLatestTurn?.turnId ?? undefined, - selectedProvider, - ), - [activeLatestTurn?.turnId, selectedProvider, threadActivities], + () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), + [activeLatestTurn?.turnId, threadActivities], ); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index da3f3b690602..abc0db79b6cb 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -9,7 +9,13 @@ import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; import ChatMarkdown from "./ChatMarkdown"; -import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon } from "lucide-react"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, +} from "lucide-react"; import { cn } from "~/lib/utils"; import type { ActivePlanState } from "../session-logic"; import type { LatestProposedPlanState } from "../session-logic"; @@ -19,16 +25,36 @@ import { buildProposedPlanMarkdownFilename, normalizePlanMarkdownForExport, downloadPlanAsTextFile, - extractProposedPlanTasks, stripDisplayedPlanMarkdown, } from "../proposedPlan"; -import { PlanTaskList } from "./PlanTaskList"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { projectEnvironment } from "~/state/projects"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useAtomCommand } from "~/state/use-atom-command"; +function stepStatusIcon(status: string): React.ReactNode { + if (status === "completed") { + return ( + + + + ); + } + if (status === "inProgress") { + return ( + + + + ); + } + return ( + + + + ); +} + interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; @@ -62,8 +88,6 @@ const PlanSidebar = memo(function PlanSidebar({ const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - const planTasks = - activePlan?.steps ?? (planMarkdown ? extractProposedPlanTasks(planMarkdown) : []); const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; @@ -179,7 +203,37 @@ const PlanSidebar = memo(function PlanSidebar({ ) : null} {/* Plan Steps */} - {planTasks.length > 0 ? : null} + {activePlan && activePlan.steps.length > 0 ? ( +
+

+ Steps +

+ {activePlan.steps.map((step) => ( +
+ {stepStatusIcon(step.status)} +

+ {step.step} +

+
+ ))} +
+ ) : null} {/* Proposed Plan Markdown */} {planMarkdown ? ( @@ -195,7 +249,7 @@ const PlanSidebar = memo(function PlanSidebar({ )} - {planTasks.length > 0 ? "Details" : (planTitle ?? "Full Plan")} + {planTitle ?? "Full Plan"} {proposedPlanExpanded ? ( diff --git a/apps/web/src/components/PlanTaskList.tsx b/apps/web/src/components/PlanTaskList.tsx deleted file mode 100644 index 0c2236b643de..000000000000 --- a/apps/web/src/components/PlanTaskList.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { CheckIcon, LoaderIcon } from "lucide-react"; - -import type { ProposedPlanTask } from "../proposedPlan"; -import { cn } from "~/lib/utils"; - -function PlanTaskStatusIcon({ status }: { status: ProposedPlanTask["status"] }) { - if (status === "completed") { - return ( - - - ); - } - if (status === "inProgress") { - return ( - - - ); - } - return ( - - - ); -} - -export function PlanTaskList({ - tasks, - label = "Tasks", -}: { - tasks: ReadonlyArray; - label?: string; -}) { - const completedCount = tasks.filter((task) => task.status === "completed").length; - - return ( -
-
-

- {label} -

- - {completedCount}/{tasks.length} - -
- {tasks.map((task, index) => ( -
- -

- {task.step} -

-
- ))} -
- ); -} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4054720e6f71..6ec2e631d198 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -110,7 +110,6 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - resolveComposerProviderInstance, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, @@ -433,7 +432,7 @@ export interface ChatComposerHandle { selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; - } | null; + }; } // -------------------------------------------------------------------------- @@ -694,7 +693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, explicitSelectedInstanceId, ) ?? ProviderDriverKind.make("codex"); - const requestedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -718,43 +717,63 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ignore picker selections). // 2. Thread's persisted instance id (server-side saved selection). // 3. Project default's instance id. - // 4. First enabled and available entry matching the current driver kind. - // 5. First enabled and available entry allowed by the thread lock. + // 4. First enabled entry matching the current driver kind. + // 5. First enabled entry overall / default instance for the kind. // - const selectedInstanceId = useMemo(() => { - return resolveComposerProviderInstance({ - entries: providerInstanceEntries, - candidates: [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ], - selectedProvider: requestedProvider, - lockedProvider, - lockedContinuationGroupKey, - }); + const selectedInstanceId = useMemo(() => { + const candidates: Array = [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const match = providerInstanceEntries.find( + (entry) => entry.instanceId === candidate && entry.enabled, + ); + if (match) { + // When locked to a specific driver kind, ignore persisted instance + // ids from a different kind or continuation group. + if (lockedProvider && match.driverKind !== lockedProvider) continue; + if ( + lockedContinuationGroupKey && + match.continuationGroupKey !== lockedContinuationGroupKey + ) { + continue; + } + return match.instanceId; + } + } + if (explicitSelectedInstanceId) { + return ProviderInstanceId.make(explicitSelectedInstanceId); + } + const byKind = providerInstanceEntries.find( + (entry) => + entry.enabled && + entry.driverKind === selectedProvider && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (byKind) return byKind.instanceId; + const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + return ( + anyEnabled?.instanceId ?? + providerInstanceEntries[0]?.instanceId ?? + activeThreadModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? + ProviderInstanceId.make("codex") + ); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, + explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, - requestedProvider, + selectedProvider, ]); - const providerSelectionUnavailable = selectedInstanceId === undefined; - const selectedProviderEntry = useMemo( - () => - selectedInstanceId - ? providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId) - : undefined, - [providerInstanceEntries, selectedInstanceId], - ); - const selectedProvider = selectedProviderEntry?.driverKind ?? requestedProvider; - const selectedInstanceIdForPicker = - selectedInstanceId ?? ProviderInstanceId.make(selectedProvider); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -769,6 +788,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Resolve the active instance's snapshot by `instanceId` so a custom // instance gets its own slash commands, skills, and model list — not // the first snapshot for the same driver kind. + const selectedProviderEntry = useMemo( + () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), + [providerInstanceEntries, selectedInstanceId], + ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -789,7 +812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model: selectedModel, models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, - modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, + modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, @@ -812,11 +835,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [providerStatuses, selectedProvider], ); - const selectedModelSelection = useMemo( - () => - selectedInstanceId - ? createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch) - : null, + const selectedModelSelection = useMemo( + () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], ); const selectedModelForPicker = selectedModel; @@ -834,9 +854,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return out; }, [providerInstanceEntries, settings]); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = selectedInstanceId - ? (modelOptionsByInstance.get(selectedInstanceId) ?? []) - : []; + const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); @@ -1099,23 +1117,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}), + instanceId: selectedInstanceId, ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, + modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}), + instanceId: selectedInstanceId, ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, - modelOptions: selectedInstanceId ? composerModelOptions?.[selectedInstanceId] : undefined, + modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, }); @@ -1137,7 +1155,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isConnecting || projectSelectionRequired || - providerSelectionUnavailable || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1676,9 +1693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || providerSelectionUnavailable || phase === "running") { - return false; - } + if (isSendBusy || isConnecting || phase === "running") return false; if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1691,27 +1706,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isMobileViewport, isSendBusy, phase, - providerSelectionUnavailable, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { - if (providerSelectionUnavailable) { - event?.preventDefault(); - return; - } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [ - blurMobileComposerAfterSend, - onSend, - providerSelectionUnavailable, - shouldBlurMobileComposerOnSubmit, - ], + [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2019,23 +2024,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, - getSendContext: () => - selectedModelSelection - ? { - prompt: promptRef.current, - images: composerImagesRef.current, - terminalContexts: composerTerminalContextsRef.current, - elementContexts: composerElementContextsRef.current, - previewAnnotations: composerPreviewAnnotations, - reviewComments: composerReviewComments, - selectedPromptEffort, - selectedModelOptionsForDispatch, - selectedModelSelection, - selectedProvider, - selectedModel, - selectedProviderModels, - } - : null, + getSendContext: () => ({ + prompt: promptRef.current, + images: composerImagesRef.current, + terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, + previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + selectedProvider, + selectedModel, + selectedProviderModels, + }), }), [ activeThread, @@ -2508,7 +2510,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
{ const content = sidebarContentRef.current; if (!content) { diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 7028353aa1ff..9746857a8cac 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -8,7 +8,6 @@ import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, downloadPlanAsTextFile, - extractProposedPlanTasks, normalizePlanMarkdownForExport, proposedPlanTitle, stripDisplayedPlanMarkdown, @@ -33,7 +32,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { projectEnvironment } from "~/state/projects"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useAtomCommand } from "~/state/use-atom-command"; -import { PlanTaskList } from "../PlanTaskList"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, @@ -72,7 +70,6 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const lineCount = planMarkdown.split("\n").length; const canCollapse = planMarkdown.length > 900 || lineCount > 20; const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); - const planTasks = extractProposedPlanTasks(planMarkdown); const collapsedPreview = canCollapse ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) : null; @@ -173,37 +170,27 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
- {planTasks.length > 0 ? : null} - {planTasks.length === 0 || expanded ? ( -
0 && - "mt-4 rounded-lg border border-border/50 bg-background/50 p-3", - planTasks.length === 0 && canCollapse && !expanded && "max-h-104 overflow-hidden", - )} - > - {planTasks.length === 0 && canCollapse && !expanded ? ( - - ) : ( - - )} - {planTasks.length === 0 && canCollapse && !expanded ? ( -
- ) : null} -
- ) : null} - {canCollapse || planTasks.length > 0 ? ( +
+ {canCollapse && !expanded ? ( + + ) : ( + + )} + {canCollapse && !expanded ? ( +
+ ) : null} +
+ {canCollapse ? (
) : null} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 65484eb595b5..ac2f7be81e83 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -153,61 +153,6 @@ function ProviderAuthEmail(props: { ); } -function ProviderAuthLabel(props: { - readonly label: string | undefined; - readonly type: string | undefined; - readonly separator?: boolean; -}) { - const label = props.label?.trim(); - if (label) { - const parts = label - .split(" - ") - .map((part) => part.trim()) - .filter((part) => part.length > 0); - const keyedParts: Array<{ - readonly key: string; - readonly part: string; - readonly separator: boolean; - }> = []; - for (const part of parts) { - const previous = keyedParts.at(-1); - keyedParts.push({ - key: previous ? `${previous.key} - ${part}` : part, - part, - separator: keyedParts.length > 0, - }); - } - - return ( - - {props.separator ? · : null} - {keyedParts.map((part) => ( - - {part.separator ? - : null} - {part.part.startsWith("@") ? ( - - ) : ( - {part.part} - )} - - ))} - - ); - } - - return props.type ? ( - - {props.separator ? · : null} - {props.type} - - ) : null; -} - function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -456,13 +401,14 @@ export function ProviderInstanceCard({ const statusKey: ProviderStatusKey = (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled"); const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; - const summary = getProviderSummary(liveProvider); + const rawSummary = getProviderSummary(liveProvider); const authEmail = liveProvider?.auth.email; - 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 hasAuthenticatedEmail = + liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); + const authenticatedDetail = hasAuthenticatedEmail + ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) + : null; + const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; @@ -633,13 +579,11 @@ export function ProviderInstanceCard({ const authRowNode = (

- {isAuthenticated ? ( + {hasAuthenticatedEmail ? ( <> - - {hasAuthenticatedEmail || hasAuthenticatedLabel ? "Authenticated as" : "Authenticated"} - + Authenticated as - + {authenticatedDetail ? · {authenticatedDetail} : null} ) : ( <> diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index fbb8e2a80004..99732b5761c9 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -5,7 +5,6 @@ import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, - extractProposedPlanTasks, proposedPlanTitle, resolvePlanFollowUpSubmission, stripDisplayedPlanMarkdown, @@ -21,112 +20,6 @@ describe("proposedPlanTitle", () => { }); }); -describe("extractProposedPlanTasks", () => { - it("prefers a named task section over descriptive lists", () => { - expect( - extractProposedPlanTasks( - [ - "# Integrate RPC", - "## Summary", - "- Keep existing behavior", - "- Avoid extra round trips", - "## Tasks", - "1. Add the shared contract", - "2. Wire the server", - "3. Render the client state", - ].join("\n"), - ), - ).toEqual([ - { step: "Add the shared contract", status: "pending" }, - { step: "Wire the server", status: "pending" }, - { step: "Render the client state", status: "pending" }, - ]); - }); - - it("prefers a named task section over a larger checklist", () => { - expect( - extractProposedPlanTasks( - [ - "# Integrate RPC", - "## Summary", - "- [x] Preserve current behavior", - "- [x] Avoid extra round trips", - "- [ ] Document the migration", - "- [ ] Add release notes", - "## Tasks", - "1. Add the shared contract", - "2. Wire the server", - "3. Render the client state", - ].join("\n"), - ), - ).toEqual([ - { step: "Add the shared contract", status: "pending" }, - { step: "Wire the server", status: "pending" }, - { step: "Render the client state", status: "pending" }, - ]); - }); - - it("combines task lists separated by prose", () => { - expect( - extractProposedPlanTasks( - [ - "# Integrate RPC", - "## Tasks", - "1. Add the shared contract", - "2. Wire the server", - "3. Render the client state", - "The implementation should preserve current behavior.", - "- [x] Existing behavior documented", - "- [ ] Release notes drafted", - ].join("\n"), - ), - ).toEqual([ - { step: "Add the shared contract", status: "pending" }, - { step: "Wire the server", status: "pending" }, - { step: "Render the client state", status: "pending" }, - { step: "Existing behavior documented", status: "completed" }, - { step: "Release notes drafted", status: "pending" }, - ]); - }); - - it("combines task lists separated by a fenced code block", () => { - expect( - extractProposedPlanTasks( - [ - "## Tasks", - "1. Add the shared contract", - "```md", - "- [ ] Not a real task", - "```", - "2. Wire the server", - ].join("\n"), - ), - ).toEqual([ - { step: "Add the shared contract", status: "pending" }, - { step: "Wire the server", status: "pending" }, - ]); - }); - - it("preserves statuses from markdown task markers", () => { - expect(extractProposedPlanTasks("## Work\n- [x] Parse tasks\n- [ ] Render statuses")).toEqual([ - { step: "Parse tasks", status: "completed" }, - { step: "Render statuses", status: "pending" }, - ]); - }); - - it("ignores lists inside fenced code blocks", () => { - expect( - extractProposedPlanTasks( - ["```md", "- [ ] Not a real task", "```", "", "1. Implement the parser"].join("\n"), - ), - ).toEqual([{ step: "Implement the parser", status: "pending" }]); - }); - - it("returns no tasks when the plan has no list", () => { - expect(extractProposedPlanTasks("# Plan\n\nImplement the feature safely.")).toEqual([]); - }); -}); - describe("buildPlanImplementationPrompt", () => { it("formats the plan exactly like the Codex follow-up handoff prompt", () => { expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index b046d3c6eba8..48186392e8a3 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -3,119 +3,6 @@ export function proposedPlanTitle(planMarkdown: string): string | null { return heading && heading.length > 0 ? heading : null; } -export interface ProposedPlanTask { - step: string; - status: "pending" | "inProgress" | "completed"; -} - -interface ProposedPlanTaskGroup { - sectionId: number; - heading: string | null; - explicitTaskCount: number; - tasks: ProposedPlanTask[]; -} - -const TASK_SECTION_HEADING = - /^(?:implementation(?: plan)?|execution(?: plan)?|plan|steps?|tasks?|work plan)$/i; - -function normalizeProposedPlanTaskText(markdown: string): string { - return markdown - .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/[*_~`]/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -export function extractProposedPlanTasks(planMarkdown: string): ProposedPlanTask[] { - const groups: ProposedPlanTaskGroup[] = []; - let heading: string | null = null; - let sectionId = 0; - let currentGroup: ProposedPlanTaskGroup | null = null; - let fenced = false; - - const finishGroup = () => { - if (currentGroup?.tasks.length) { - groups.push(currentGroup); - } - currentGroup = null; - }; - - for (const line of planMarkdown.split(/\r?\n/)) { - if (/^\s{0,3}(?:```|~~~)/.test(line)) { - fenced = !fenced; - finishGroup(); - continue; - } - if (fenced) { - continue; - } - - const headingMatch = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); - if (headingMatch) { - finishGroup(); - sectionId += 1; - heading = normalizeProposedPlanTaskText(headingMatch[1] ?? "") || null; - continue; - } - - const itemMatch = line.match(/^\s{0,3}(?:[-+*]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.+?)\s*$/); - if (itemMatch) { - const step = normalizeProposedPlanTaskText(itemMatch[2] ?? ""); - if (!step) { - continue; - } - currentGroup ??= { - sectionId, - heading, - explicitTaskCount: 0, - tasks: [], - }; - const marker = itemMatch[1]; - if (marker !== undefined) { - currentGroup.explicitTaskCount += 1; - } - currentGroup.tasks.push({ - step, - status: marker?.toLowerCase() === "x" ? "completed" : "pending", - }); - continue; - } - - if (line.trim().length > 0 && !/^\s{4,}/.test(line)) { - finishGroup(); - } - } - finishGroup(); - - const consolidatedGroups = groups.reduce((result, group) => { - const previous = result.at(-1); - if (previous?.sectionId === group.sectionId && TASK_SECTION_HEADING.test(group.heading ?? "")) { - previous.explicitTaskCount += group.explicitTaskCount; - previous.tasks.push(...group.tasks); - return result; - } - result.push({ ...group, tasks: [...group.tasks] }); - return result; - }, []); - - const rankedGroups = consolidatedGroups.toSorted((left, right) => { - const headingDifference = - Number(TASK_SECTION_HEADING.test(right.heading ?? "")) - - Number(TASK_SECTION_HEADING.test(left.heading ?? "")); - if (headingDifference !== 0) { - return headingDifference; - } - const explicitDifference = right.explicitTaskCount - left.explicitTaskCount; - if (explicitDifference !== 0) { - return explicitDifference; - } - return right.tasks.length - left.tasks.length; - }); - - return rankedGroups[0]?.tasks ?? []; -} - export function stripDisplayedPlanMarkdown(planMarkdown: string): string { const lines = planMarkdown.trimEnd().split(/\r?\n/); const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 65df0f417578..a5b03f563281 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -5,7 +5,6 @@ import { deriveProviderInstanceEntries, isProviderInstancePickerReady, isProviderInstancePickerVisible, - resolveComposerProviderInstance, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; @@ -124,62 +123,34 @@ describe("deriveProviderInstanceEntries", () => { describe("resolveSelectableProviderInstance", () => { it("returns the requested instance when it is enabled and available", () => { const requested = ProviderInstanceId.make("claude_work"); - const entries = deriveProviderInstanceEntries([ + const providers = [ provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: requested }), - ]); + ]; - expect(resolveSelectableProviderInstance(entries, requested)).toBe(requested); + expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested); }); it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); const fallback = ProviderInstanceId.make("claudeAgent"); - const entries = deriveProviderInstanceEntries([ + const providers = [ provider({ provider: ProviderDriverKind.make("codex"), instanceId: disabled, enabled: false, }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: fallback }), - ]); - - expect(resolveSelectableProviderInstance(entries, disabled)).toBe(fallback); - }); - - it("falls back from a removed custom Copilot instance to a selectable instance", () => { - const removed = ProviderInstanceId.make("copilot_work"); - const fallback = ProviderInstanceId.make("copilot_personal"); - const entries = applyProviderInstanceSettings( - deriveProviderInstanceEntries([ - provider({ - provider: ProviderDriverKind.make("copilot"), - instanceId: removed, - }), - provider({ - provider: ProviderDriverKind.make("copilot"), - instanceId: fallback, - }), - ]), - { - providerInstances: { - [fallback]: { - driver: ProviderDriverKind.make("copilot"), - enabled: true, - }, - }, - providers: {} as never, - }, - ); + ]; - expect(resolveSelectableProviderInstance(entries, removed)).toBe(fallback); + expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); }); it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { const disabled = ProviderInstanceId.make("codex"); const unavailable = ProviderInstanceId.make("claudeAgent"); const unknown = ProviderInstanceId.make("removed_instance"); - const entries = deriveProviderInstanceEntries([ + const providers = [ provider({ provider: ProviderDriverKind.make("codex"), instanceId: disabled, @@ -190,55 +161,11 @@ describe("resolveSelectableProviderInstance", () => { instanceId: unavailable, availability: "unavailable", }), - ]); - - expect(resolveSelectableProviderInstance(entries, disabled)).toBeUndefined(); - expect(resolveSelectableProviderInstance(entries, unavailable)).toBeUndefined(); - expect(resolveSelectableProviderInstance(entries, unknown)).toBeUndefined(); - }); -}); - -describe("resolveComposerProviderInstance", () => { - it("falls back from an unavailable draft selection to an available provider", () => { - const unavailable = ProviderInstanceId.make("copilot"); - const fallback = ProviderInstanceId.make("codex"); - const entries = deriveProviderInstanceEntries([ - provider({ - provider: ProviderDriverKind.make("copilot"), - instanceId: unavailable, - availability: "unavailable", - }), - provider({ provider: ProviderDriverKind.make("codex"), instanceId: fallback }), - ]); - - expect( - resolveComposerProviderInstance({ - entries, - candidates: [unavailable], - selectedProvider: ProviderDriverKind.make("copilot"), - }), - ).toBe(fallback); - }); - - it("returns no selection instead of routing a locked thread through an unavailable instance", () => { - const unavailable = ProviderInstanceId.make("copilot"); - const entries = deriveProviderInstanceEntries([ - provider({ - provider: ProviderDriverKind.make("copilot"), - instanceId: unavailable, - availability: "unavailable", - }), - provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), - ]); + ]; - expect( - resolveComposerProviderInstance({ - entries, - candidates: [unavailable], - selectedProvider: ProviderDriverKind.make("copilot"), - lockedProvider: ProviderDriverKind.make("copilot"), - }), - ).toBeUndefined(); + expect(resolveSelectableProviderInstance(providers, disabled)).toBeUndefined(); + expect(resolveSelectableProviderInstance(providers, unavailable)).toBeUndefined(); + expect(resolveSelectableProviderInstance(providers, unknown)).toBeUndefined(); }); }); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 43cbebe90dd4..c9ac87ac39f1 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -260,12 +260,15 @@ export function getProviderInstanceModels( * as a fallback so downstream code can still send a turn. */ export function resolveSelectableProviderInstance( - entries: ReadonlyArray, + providers: ReadonlyArray, instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { if (instanceId === undefined) { - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return deriveProviderInstanceEntries(providers).find( + (entry) => entry.enabled && entry.isAvailable, + )?.instanceId; } + const entries = deriveProviderInstanceEntries(providers); const requested = entries.find((entry) => entry.instanceId === instanceId); if (requested && requested.enabled && requested.isAvailable) { return instanceId; @@ -273,40 +276,6 @@ export function resolveSelectableProviderInstance( return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; } -/** Resolve the composer target without crossing a thread's provider or continuation lock. */ -export function resolveComposerProviderInstance(input: { - entries: ReadonlyArray; - candidates: ReadonlyArray; - selectedProvider: ProviderDriverKind; - lockedProvider?: ProviderDriverKind | null | undefined; - lockedContinuationGroupKey?: string | null | undefined; -}): ProviderInstanceId | undefined { - const isSelectable = (entry: ProviderInstanceEntry): boolean => - entry.enabled && - entry.isAvailable && - (!input.lockedProvider || entry.driverKind === input.lockedProvider) && - (!input.lockedContinuationGroupKey || - entry.continuationGroupKey === input.lockedContinuationGroupKey); - - for (const candidate of input.candidates) { - if (!candidate) { - continue; - } - const match = input.entries.find( - (entry) => entry.instanceId === candidate && isSelectable(entry), - ); - if (match) { - return match.instanceId; - } - } - - return ( - input.entries.find( - (entry) => isSelectable(entry) && entry.driverKind === input.selectedProvider, - )?.instanceId ?? input.entries.find(isSelectable)?.instanceId - ); -} - /** * Resolve an open model-selection routing key back to a driver kind. * Custom instance ids such as `claude_openrouter` are not themselves diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 58a21adcbd28..b722c04f0aec 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1,7 +1,6 @@ import { EventId, MessageId, - ProviderDriverKind, ThreadId, TurnId, type OrchestrationThreadActivity, @@ -311,27 +310,6 @@ describe("derivePendingUserInputs", () => { }); describe("deriveActivePlanState", () => { - it("does not expose Copilot task updates as a plan", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "copilot-tasks", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - explanation: "Copilot Tasks", - plan: [{ step: "Inspect implementation", status: "inProgress" }], - }, - }), - ]; - - expect( - deriveActivePlanState(activities, TurnId.make("turn-1"), ProviderDriverKind.make("copilot")), - ).toBeNull(); - }); - it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -713,41 +691,27 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("hides noisy resolved approval and plan update rows for Copilot only", () => { + it("omits resolved approval entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "approval-requested", + createdAt: "2026-02-23T00:00:01.000Z", kind: "approval.requested", - summary: "Command approval requested", + summary: "Approval requested", tone: "approval", - payload: { requestId: "req-1", requestKind: "command" }, }), makeActivity({ id: "approval-resolved", + createdAt: "2026-02-23T00:00:02.000Z", kind: "approval.resolved", summary: "Approval resolved", tone: "approval", - payload: { requestId: "req-1", requestKind: "command" }, - }), - makeActivity({ - id: "plan-updated", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - payload: { - plan: [{ step: "Implement the change", status: "inProgress" }], - }, }), ]; - expect( - deriveWorkLogEntries(activities, ProviderDriverKind.make("copilot")).map((entry) => entry.id), - ).toEqual(["approval-requested"]); - expect( - deriveWorkLogEntries(activities, ProviderDriverKind.make("codex")) - .map((entry) => entry.id) - .toSorted(), - ).toEqual(["approval-requested", "approval-resolved", "plan-updated"]); + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "approval-requested", + ]); }); it("omits tool started entries and keeps completed entries", () => { @@ -1178,38 +1142,6 @@ describe("deriveWorkLogEntries", () => { }); }); - it("uses Copilot command metadata instead of command output detail", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "copilot-command-complete", - kind: "tool.completed", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - status: "completed", - detail: " M apps/server/src/provider/Layers/CopilotAdapter.ts", - data: { - toolCallId: "tool-command", - toolName: "bash", - command: "git status --short", - result: { - content: " M apps/server/src/provider/Layers/CopilotAdapter.ts", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry).toMatchObject({ - command: "git status --short", - detail: "M apps/server/src/provider/Layers/CopilotAdapter.ts", - itemType: "command_execution", - toolTitle: "Ran command", - }); - }); - it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index ada1a84f746c..34c155916658 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -511,11 +511,7 @@ export function derivePendingUserInputs( export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, - provider?: ProviderDriverKind, ): ActivePlanState | null { - if (provider === "copilot") { - return null; - } const ordered = [...activities].toSorted(compareActivitiesByOrder); const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); // Prefer plan from the current turn; fall back to the most recent plan from any turn @@ -631,17 +627,11 @@ export function hasActionableProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, - provider?: ProviderDriverKind, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if ( - provider === "copilot" && - (activity.kind === "approval.resolved" || activity.kind === "turn.plan.updated") - ) { - continue; - } + if (activity.kind === "approval.resolved") continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ee290701885c..670540fee701 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -385,7 +385,6 @@ export function applyThreadDetailEvent( // checkpoint, but don't settle a turn its session is still running. const diffTurnStillRunning = thread.session?.status === "running" && - thread.session.activeTurnId !== null && thread.session.activeTurnId === event.payload.turnId; const latestTurn = !diffTurnStillRunning && diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 14ab32467c29..8e4c654e2e4c 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -54,42 +54,4 @@ describe("makeDrainableWorker", () => { }), ), ); - - it.live("processes work concurrently while still waiting for every item when draining", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make(); - const secondStarted = yield* Deferred.make(); - const release = yield* Deferred.make(); - - const worker = yield* makeDrainableWorker( - (item: string) => - Effect.gen(function* () { - yield* Deferred.succeed( - item === "first" ? firstStarted : secondStarted, - undefined, - ).pipe(Effect.orDie); - yield* Deferred.await(release); - }), - { concurrency: "unbounded" }, - ); - - yield* worker.enqueue("first"); - yield* worker.enqueue("second"); - yield* Deferred.await(firstStarted); - yield* Deferred.await(secondStarted); - - const drained = yield* Deferred.make(); - yield* Effect.forkChild( - worker.drain.pipe( - Effect.tap(() => Deferred.succeed(drained, undefined).pipe(Effect.orDie)), - ), - ); - expect(yield* Deferred.isDone(drained)).toBe(false); - - yield* Deferred.succeed(release, undefined); - yield* Deferred.await(drained); - }), - ), - ); }); diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 4e88ff042807..de40ec5e36b8 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -28,11 +28,6 @@ export interface DrainableWorker { readonly drain: Effect.Effect; } -export interface DrainableWorkerOptions { - /** Process queued items concurrently. Sequential processing remains the default. */ - readonly concurrency?: "unbounded" | undefined; -} - /** * Create a drainable worker that processes items from an unbounded queue. * @@ -44,34 +39,22 @@ export interface DrainableWorkerOptions { */ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, - options?: DrainableWorkerOptions, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); const outstanding = yield* TxRef.make(0); - const processNext = TxQueue.take(queue).pipe( - Effect.flatMap((item) => + yield* TxQueue.take(queue).pipe( + Effect.tap((a) => Effect.ensuring( - process(item), + process(a), TxRef.update(outstanding, (n) => n - 1), ), ), + Effect.forever, + Effect.forkScoped, ); - yield* ( - options?.concurrency === "unbounded" - ? TxQueue.take(queue).pipe( - Effect.flatMap((item) => - Effect.ensuring( - process(item), - TxRef.update(outstanding, (n) => n - 1), - ).pipe(Effect.forkScoped), - ), - ) - : processNext - ).pipe(Effect.forever, Effect.forkScoped); - const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( Effect.tap((n) => (n > 0 ? Effect.txRetry : Effect.void)), Effect.tx, diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 655567297d8c..28e07c04e6f3 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -154,39 +154,6 @@ describe("projectThreadAwareness", () => { expect(state?.phase).toBe("completed"); }); - it("keeps running threads in running phase even when updates are delayed", () => { - const state = projectThreadAwareness({ - environmentId: "env-1" as EnvironmentId, - project, - thread: thread({ - updatedAt: NOW, - session: { - threadId: "thread-1" as ThreadId, - status: "running", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: "turn-1" as TurnId, - lastError: null, - updatedAt: NOW, - }, - latestTurn: { - turnId: "turn-1" as TurnId, - state: "running", - requestedAt: NOW, - startedAt: NOW, - completedAt: null, - assistantMessageId: null, - }, - }), - }); - - expect(state).toMatchObject({ - phase: "running", - headline: "Agent is working", - detail: "Codex is active.", - }); - }); - 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 6070b5e835d6..c0f5842eb7c5 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -148,8 +148,5 @@ 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; } From dbe1f341ef89212aa7b50b23014604624ac18ea6 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 16 Jul 2026 00:35:35 +0200 Subject: [PATCH 152/155] fix(server): subscribe before watching provider settings --- .../provider/Layers/ProviderInstanceRegistryHydration.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a6..08027f3895ec 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -118,7 +118,13 @@ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; - yield* serverSettings.streamChanges.pipe( + // Acquire the stream's pull in this fiber before forking the consumer. + // For PubSub-backed settings streams this installs the subscription + // synchronously, so an update published immediately after the layer is + // built cannot land in the gap between `forkScoped` scheduling the child + // fiber and that child starting the stream. + const settingsPull = yield* Stream.toPull(serverSettings.streamChanges); + yield* Stream.fromPull(Effect.succeed(settingsPull)).pipe( Stream.runForEach((next) => mutator .reconcile(deriveProviderInstanceConfigMap(next)) From 3dffaee847f89f6f38756f7452fb7c772d91bf97 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 16 Jul 2026 01:07:53 +0200 Subject: [PATCH 153/155] fix(server): replay latest settings snapshot --- .../Layers/ProviderInstanceRegistryHydration.ts | 11 ++++------- .../src/provider/Layers/ProviderRegistry.test.ts | 4 +++- apps/server/src/serverSettings.ts | 6 +++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 08027f3895ec..304549885349 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -118,13 +118,10 @@ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; - // Acquire the stream's pull in this fiber before forking the consumer. - // For PubSub-backed settings streams this installs the subscription - // synchronously, so an update published immediately after the layer is - // built cannot land in the gap between `forkScoped` scheduling the child - // fiber and that child starting the stream. - const settingsPull = yield* Stream.toPull(serverSettings.streamChanges); - yield* Stream.fromPull(Effect.succeed(settingsPull)).pipe( + // `ServerSettingsService` replays its latest full snapshot, so a change + // published between initial hydration and this lazy stream subscription + // is still reconciled when the watcher starts. + yield* serverSettings.streamChanges.pipe( Stream.runForEach((next) => mutator .reconcile(deriveProviderInstanceConfigMap(next)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1a39d01176ca..dd37803988e2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -278,7 +278,9 @@ function makeMutableServerSettingsService( ) { return Effect.gen(function* () { const settingsRef = yield* Ref.make(initial); - const changes = yield* PubSub.unbounded(); + // Mirror ServerSettingsService: change events are full snapshots and the + // latest one is replayed to consumers that subscribe after a write. + const changes = yield* PubSub.unbounded({ replay: 1 }); return { start: Effect.void, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640fe..96db3f00a0c7 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -262,7 +262,11 @@ const make = Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; - const changesPubSub = yield* PubSub.unbounded(); + // Settings events are complete snapshots, so retaining only the latest + // value lets late subscribers catch up without replaying obsolete writes. + // In particular, this closes the gap between reading the initial settings + // and starting a change-stream consumer. + const changesPubSub = yield* PubSub.unbounded({ replay: 1 }); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); const watcherScope = yield* Scope.make("sequential"); From c6d8898b46256cfcf12b2040a76f83e098bfd423 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 16 Jul 2026 01:35:59 +0200 Subject: [PATCH 154/155] fix(copilot): address lifecycle review findings --- .../provider/Layers/CopilotAdapter.test.ts | 135 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 64 +++++---- .../src/provider/copilotMcpBearerAuth.ts | 3 + 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 29e92e9f40b3..69cd6e38e090 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -392,7 +392,7 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { { requestId: "mcp-oauth-token", serverName: "t3-code", - serverUrl: "http://127.0.0.1:43123/mcp", + serverUrl: `http://127.0.0.1:43123/mcp/?credential=${urlSecret}`, reason: "initial", resourceMetadata: `{"private":"${metadataSecret}"}`, staticClientConfig: { @@ -1245,6 +1245,62 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("keeps an active session running while configuring a queued turn", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-queued-turn-configuration-state"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "keep this turn active", + attachments: [], + }); + + runtimeMock.state.lastSession.rpc.mode.set.mockClear(); + let markModeSetStarted!: () => void; + const modeSetStarted = new Promise((resolve) => { + markModeSetStarted = resolve; + }); + let releaseModeSet!: () => void; + const modeSetGate = new Promise((resolve) => { + releaseModeSet = resolve; + }); + runtimeMock.state.lastSession.rpc.mode.set.mockImplementationOnce(async () => { + markModeSetStarted(); + await modeSetGate; + }); + + const queuedTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "queue a differently configured turn", + attachments: [], + interactionMode: "plan", + modelSelection: { + instanceId: COPILOT_INSTANCE_ID, + model: "claude-sonnet-4.6", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => modeSetStarted); + + const sessionWhileConfiguring = (yield* adapter.listSessions()).at(0); + NodeAssert.equal(sessionWhileConfiguring?.status, "running"); + NodeAssert.equal(String(sessionWhileConfiguring?.activeTurnId), String(activeTurn.turnId)); + + releaseModeSet(); + yield* Fiber.join(queuedTurnFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("returns a session-scoped SDK approval for acceptForSession", () => Effect.gen(function* () { const adapter = yield* CopilotAdapter; @@ -3424,6 +3480,83 @@ it.layer(CopilotAdapterTestLayer)("CopilotAdapterLive", (it) => { }), ); + it.effect("resolves open user input requests when stopping a session", () => + Effect.gen(function* () { + const adapter = yield* CopilotAdapter; + const threadId = asThreadId("copilot-stop-resolves-user-input"); + + yield* adapter.startSession({ + provider: COPILOT_DRIVER, + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => Effect.sync(() => runtimeEvents.push(event))), + Effect.forkChild, + ); + yield* waitForSdkEventQueue(); + + const config = runtimeMock.state.createSessionConfigs.at(-1); + NodeAssert.ok(config?.onEvent); + NodeAssert.ok(config.onUserInputRequest); + const requestId = "user-input-stop-open"; + const request = { + question: "Should Copilot continue?", + choices: ["Continue", "Stop"], + allowFreeform: false, + }; + const responsePromise = Promise.resolve( + config.onUserInputRequest(request, { + sessionId: runtimeMock.state.lastSession.sessionId, + }), + ); + const timestamp = yield* nowIso; + config.onEvent({ + id: "evt-copilot-stop-open-user-input", + timestamp, + parentId: null, + type: "user_input.requested", + data: { + requestId, + ...request, + }, + } as SessionEvent); + + let requested: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && requested === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + requested = runtimeEvents.find( + (event) => event.type === "user-input.requested" && String(event.requestId) === requestId, + ); + } + NodeAssert.equal(requested?.type, "user-input.requested"); + + yield* adapter.stopSession(threadId); + const response = yield* Effect.promise(() => responsePromise); + NodeAssert.deepStrictEqual(response, { + answer: "", + wasFreeform: true, + }); + + let resolved: ProviderRuntimeEvent | undefined; + for (let attempt = 0; attempt < 20 && resolved === undefined; attempt += 1) { + yield* waitForSdkEventQueue(); + resolved = runtimeEvents.find( + (event) => event.type === "user-input.resolved" && String(event.requestId) === requestId, + ); + } + yield* Fiber.interrupt(runtimeEventsFiber).pipe(Effect.ignore); + + NodeAssert.equal(resolved?.type, "user-input.resolved"); + if (resolved?.type === "user-input.resolved") { + NodeAssert.deepStrictEqual(resolved.payload.answers, { answer: "" }); + } + }), + ); + 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 8b8866652660..3b9221df4afe 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -998,7 +998,10 @@ function settlePendingPermissionHandlers( }); } -function settlePendingUserInputs(context: CopilotSessionContext): Effect.Effect { +function settlePendingUserInputs( + context: CopilotSessionContext, + onBindingSettled?: (binding: PendingUserInputBinding) => Effect.Effect, +): Effect.Effect { return Effect.gen(function* () { for (const handlers of context.pendingUserInputHandlersBySignature.values()) { for (const handler of handlers) { @@ -1008,10 +1011,13 @@ function settlePendingUserInputs(context: CopilotSessionContext): Effect.Effect< context.pendingUserInputHandlersBySignature.clear(); context.pendingUserInputEventsBySignature.clear(); - for (const binding of context.pendingUserInputBindings.values()) { + for (const [requestId, binding] of context.pendingUserInputBindings.entries()) { + context.pendingUserInputBindings.delete(requestId); yield* Deferred.succeed(binding.deferred, EMPTY_USER_INPUT_RESPONSE).pipe(Effect.ignore); + if (onBindingSettled) { + yield* onBindingSettled(binding).pipe(Effect.ignore); + } } - context.pendingUserInputBindings.clear(); }); } @@ -1896,6 +1902,24 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( }); }; + const emitUserInputResolved = ( + context: CopilotSessionContext, + binding: PendingUserInputBinding, + answers: ProviderUserInputAnswers, + raw?: SessionEvent, + ): Effect.Effect => + emit({ + ...createBaseEvent({ + threadId: context.threadId, + requestId: binding.requestId, + raw, + }), + type: "user-input.resolved", + payload: { + answers, + }, + }); + const bindPermissionRequests = ( context: CopilotSessionContext, signature: string, @@ -2967,17 +2991,9 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( return; } context.pendingUserInputBindings.delete(event.data.requestId); - await emitAsync({ - ...createBaseEvent({ - threadId: context.threadId, - requestId: binding.requestId, - raw: event, - }), - type: "user-input.resolved", - payload: { - answers: answersFromCompletedUserInput(event.data), - }, - }); + await runWithContext( + emitUserInputResolved(context, binding, answersFromCompletedUserInput(event.data), event), + ); return; } case "exit_plan_mode.requested": { @@ -3310,7 +3326,6 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( yield* copilotSdk.setModel(context, modelSelection.model, reasoningEffort, contextTier); updateProviderSession(context, { model: modelSelection.model, - ...(reasoningEffort || contextTier ? { status: "ready" } : {}), }); } yield* syncSessionMode(context, mode); @@ -3505,17 +3520,8 @@ 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, - }, - }, + yield* emitUserInputResolved(context, binding, { + answer: response.answer, }); context.pendingUserInputBindings.delete(requestId); yield* Deferred.succeed(binding.deferred, response); @@ -3547,7 +3553,11 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( yield* settlePendingPermissionHandlers(context, (binding) => emitPermissionRequestResolved(context, binding, "reject", DENIED_PERMISSION_RESULT), ); - yield* settlePendingUserInputs(context); + yield* settlePendingUserInputs(context, (binding) => + emitUserInputResolved(context, binding, { + answer: EMPTY_USER_INPUT_RESPONSE.answer, + }), + ); yield* copilotSdk.disconnect(context).pipe(Effect.ignore); yield* copilotSdk.stopClient(threadId, context.client).pipe(Effect.ignore({ log: true })); diff --git a/apps/server/src/provider/copilotMcpBearerAuth.ts b/apps/server/src/provider/copilotMcpBearerAuth.ts index 9d7ad2b18a68..10b23fc82559 100644 --- a/apps/server/src/provider/copilotMcpBearerAuth.ts +++ b/apps/server/src/provider/copilotMcpBearerAuth.ts @@ -19,6 +19,9 @@ function normalizeCopilotMcpEndpoint(raw: string): string | undefined { if (url.pathname.length > 1) { url.pathname = url.pathname.replace(/\/+$/, ""); } + // Query parameters may carry short-lived credentials supplied by the SDK; + // they are not part of the configured MCP endpoint identity. + url.search = ""; return url.href; } catch { return undefined; From 5e461d823a4418097dcf4aba0645bc7575d712cc Mon Sep 17 00:00:00 2001 From: huxcrux Date: Thu, 16 Jul 2026 01:46:48 +0200 Subject: [PATCH 155/155] fix(copilot): structure client stop failures --- .../src/provider/copilotRuntime.test.ts | 12 +++- apps/server/src/provider/copilotRuntime.ts | 65 +++++-------------- 2 files changed, 27 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/copilotRuntime.test.ts b/apps/server/src/provider/copilotRuntime.test.ts index 95ee1c45ce87..d0193819c230 100644 --- a/apps/server/src/provider/copilotRuntime.test.ts +++ b/apps/server/src/provider/copilotRuntime.test.ts @@ -47,7 +47,11 @@ describe("stopCopilotClient", () => { NodeAssert.equal(forceStopCalls, 1); NodeAssert.deepStrictEqual(error.cleanupErrors, [cleanupError]); - NodeAssert.match(error.message, /runtime shutdown timed out/); + NodeAssert.equal( + error.message, + "Copilot client cleanup was incomplete (cleanupErrors=1, gracefulStopFailures=0, forceStopFailures=0).", + ); + NodeAssert.doesNotMatch(error.message, /runtime shutdown timed out/); }), ); @@ -81,7 +85,11 @@ describe("stopCopilotClient", () => { const error = yield* Fiber.join(stopFiber); NodeAssert.equal(forceStopCalls, 1); - NodeAssert.match(error.message, /timed out after 5 seconds/); + NodeAssert.equal( + error.message, + "Copilot client cleanup was incomplete (cleanupErrors=0, gracefulStopFailures=1, forceStopFailures=0).", + ); + NodeAssert.doesNotMatch(error.message, /timed out after 5 seconds/); NodeAssert.match(String(error.stopCause), /timed out after 5 seconds/); }), ); diff --git a/apps/server/src/provider/copilotRuntime.ts b/apps/server/src/provider/copilotRuntime.ts index 9f7aa20b3c51..0bb494b6e170 100644 --- a/apps/server/src/provider/copilotRuntime.ts +++ b/apps/server/src/provider/copilotRuntime.ts @@ -66,36 +66,18 @@ export class CopilotProbePromiseError extends Schema.TaggedErrorClass; - readonly stopCause: unknown | undefined; - readonly forceStopCause: unknown | undefined; - - constructor(input: { - readonly cleanupErrors?: ReadonlyArray; - readonly stopCause?: unknown; - readonly forceStopCause?: unknown; - }) { - const cleanupErrors = input.cleanupErrors ?? []; - const details = [ - ...cleanupErrors.map((error) => error.message), - input.stopCause === undefined - ? undefined - : describeCopilotStopCause(input.stopCause, "Graceful stop rejected."), - input.forceStopCause === undefined - ? undefined - : describeCopilotStopCause(input.forceStopCause, "Force stop rejected."), - ].filter((detail): detail is string => detail !== undefined); - super( - details.length > 0 - ? `Copilot client cleanup was incomplete: ${details.join(" ")}` - : "Copilot client cleanup was incomplete.", - ); - this.name = "CopilotClientStopError"; - this.cleanupErrors = cleanupErrors; - this.stopCause = input.stopCause; - this.forceStopCause = input.forceStopCause; +export class CopilotClientStopError extends Schema.TaggedErrorClass()( + "CopilotClientStopError", + { + cleanupErrors: Schema.Array(Schema.Defect()), + stopCause: Schema.optional(Schema.Defect()), + forceStopCause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const gracefulStopFailures = this.stopCause === undefined ? 0 : 1; + const forceStopFailures = this.forceStopCause === undefined ? 0 : 1; + return `Copilot client cleanup was incomplete (cleanupErrors=${this.cleanupErrors.length}, gracefulStopFailures=${gracefulStopFailures}, forceStopFailures=${forceStopFailures}).`; } } @@ -107,16 +89,6 @@ class CopilotClientStopPromiseError extends Schema.TaggedErrorClass 0) { - return cause.message.trim(); - } - if (typeof cause === "string" && cause.trim().length > 0) { - return cause.trim(); - } - return fallback; -} - export const stopCopilotClient = Effect.fn("stopCopilotClient")(function* ( client: Pick, ) { @@ -154,14 +126,11 @@ export const stopCopilotClient = Effect.fn("stopCopilotClient")(function* ( }), ); - return yield* Effect.fail( - new CopilotClientStopError({ - ...(stopAttempt._tag === "Resolved" - ? { cleanupErrors: stopAttempt.cleanupErrors } - : { stopCause: stopAttempt.cause }), - ...(forceStopAttempt._tag === "Rejected" ? { forceStopCause: forceStopAttempt.cause } : {}), - }), - ); + return yield* new CopilotClientStopError({ + cleanupErrors: stopAttempt._tag === "Resolved" ? stopAttempt.cleanupErrors : [], + ...(stopAttempt._tag === "Rejected" ? { stopCause: stopAttempt.cause } : {}), + ...(forceStopAttempt._tag === "Rejected" ? { forceStopCause: forceStopAttempt.cause } : {}), + }); }); class CopilotCliPathResolutionError extends Schema.TaggedErrorClass()(