From 1476b54599532bfc53982c602d50db9e829e2738 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 08:45:41 +0100 Subject: [PATCH 01/14] feat: start GitHub Copilot adapter From 8703ef06bd542981eff0845a15f6d18bcb019a0c Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 09:24:47 +0100 Subject: [PATCH 02/14] feat: add initial GitHub Copilot adapter Wire GitHub Copilot into the provider stack, shared contracts, and chat settings so the app can start and configure Copilot sessions alongside Codex. --- apps/server/package.json | 1 + .../Layers/ProviderCommandReactor.ts | 26 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 +- .../src/provider/Layers/CopilotAdapter.ts | 1275 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 27 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 84 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/CopilotAdapter.ts | 12 + apps/server/src/serverLayers.ts | 5 + apps/web/src/appSettings.test.ts | 16 + apps/web/src/appSettings.ts | 11 + apps/web/src/components/ChatView.browser.tsx | 4 +- apps/web/src/components/ChatView.tsx | 71 +- apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 78 +- apps/web/src/session-logic.test.ts | 1 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 9 +- bun.lock | 23 +- packages/contracts/src/model.ts | 52 + packages/contracts/src/orchestration.test.ts | 28 + packages/contracts/src/orchestration.ts | 20 +- packages/contracts/src/provider.test.ts | 33 + packages/contracts/src/provider.ts | 10 +- packages/contracts/src/providerRuntime.ts | 2 + packages/shared/src/model.test.ts | 2 + packages/shared/src/model.ts | 1 + 28 files changed, 1758 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.ts create mode 100644 apps/server/src/provider/Services/CopilotAdapter.ts diff --git a/apps/server/package.json b/apps/server/package.json index aadee2cc..f35779c3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "test": "vitest run" }, "dependencies": { + "@github/copilot-sdk": "^0.1.8", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc..ea6bfef6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,8 +4,9 @@ import { EventId, type OrchestrationEvent, type ProviderModelOptions, - type ProviderKind, + ProviderKind, type ProviderServiceTier, + type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -203,6 +204,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly modelOptions?: ProviderModelOptions; readonly serviceTier?: ProviderServiceTier | null; + readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -212,8 +214,10 @@ const make = Effect.gen(function* () { } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + const currentProvider = + thread.session?.providerName && Schema.is(ProviderKind)(thread.session.providerName) + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -239,6 +243,7 @@ const make = Effect.gen(function* () { ...(desiredModel ? { model: desiredModel } : {}), ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -322,11 +327,12 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly provider?: ProviderKind; - readonly model?: string; - readonly serviceTier?: ProviderServiceTier | null; - readonly modelOptions?: ProviderModelOptions; - readonly interactionMode?: "default" | "plan"; - readonly createdAt: string; + readonly model?: string; + readonly serviceTier?: ProviderServiceTier | null; + readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderStartOptions; + readonly interactionMode?: "default" | "plan"; + readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); if (!thread) { @@ -337,6 +343,7 @@ const make = Effect.gen(function* () { ...(input.model !== undefined ? { model: input.model } : {}), ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -472,6 +479,9 @@ const make = Effect.gen(function* () { ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + ...(event.payload.providerOptions !== undefined + ? { providerOptions: event.payload.providerOptions } + : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index eb230e52..9ab46e29 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -41,7 +41,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderRuntimeEvent["provider"]; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts new file mode 100644 index 00000000..85e27fbd --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -0,0 +1,1275 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + CopilotClient, + type CopilotSession, + type PermissionRequest, + type PermissionRequestResult, + type SessionEvent, +} from "@github/copilot-sdk"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import type { + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "copilot" as const; +const USER_INPUT_QUESTION_ID = "answer"; +const TURN_START_TIMEOUT_MS = 1_500; + +export interface CopilotAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface PendingApprovalRequest { + readonly requestType: + | "command_execution_approval" + | "file_change_approval" + | "file_read_approval" + | "dynamic_tool_call" + | "unknown"; + readonly resolve: (result: PermissionRequestResult) => void; +} + +interface CopilotUserInputRequest { + readonly question: string; + readonly choices?: ReadonlyArray; + readonly allowFreeform?: boolean; +} + +interface CopilotUserInputResponse { + readonly answer: string; + readonly wasFreeform: boolean; +} + +interface PendingUserInputRequest { + readonly request: CopilotUserInputRequest; + readonly resolve: (result: CopilotUserInputResponse) => void; +} + +interface ActiveCopilotSession { + readonly client: CopilotClient; + readonly session: CopilotSession; + readonly threadId: ThreadId; + readonly createdAt: string; + readonly runtimeMode: ProviderSession["runtimeMode"]; + cwd: string | undefined; + model: string | undefined; + updatedAt: string; + lastError: string | undefined; + currentTurnId: TurnId | undefined; + pendingTurnStarts: Array<(turnId: TurnId) => void>; + pendingApprovalResolvers: Map; + pendingUserInputResolvers: Map; + unsubscribe: () => void; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function makeEventId(prefix: string) { + return EventId.makeUnsafe(`${prefix}-${randomUUID()}`); +} + +function toTurnId(value: string | undefined): TurnId | undefined { + if (!value || value.trim().length === 0) return undefined; + return TurnId.makeUnsafe(value); +} + +function toRuntimeItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeItemId.makeUnsafe(value); +} + +function toProviderItemId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return ProviderItemId.makeUnsafe(value); +} + +function toRuntimeRequestId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeRequestId.makeUnsafe(value); +} + +function toRuntimeTaskId(value: string | undefined) { + if (!value || value.trim().length === 0) return undefined; + return RuntimeTaskId.makeUnsafe(value); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function trimToUndefined(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function extractResumeSessionId(resumeCursor: unknown): string | undefined { + if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { + return resumeCursor.trim(); + } + const record = asRecord(resumeCursor); + const sessionId = normalizeString(record?.sessionId); + return sessionId; +} + +function approvalDecisionToPermissionResult( + decision: ProviderApprovalDecision, +): PermissionRequestResult { + switch (decision) { + case "accept": + case "acceptForSession": + return { kind: "approved" }; + case "decline": + case "cancel": + default: + return { kind: "denied-interactively-by-user" }; + } +} + +function requestTypeFromPermissionRequest(request: PermissionRequest) { + switch (request.kind) { + case "shell": + return "command_execution_approval" as const; + case "write": + return "file_change_approval" as const; + case "read": + return "file_read_approval" as const; + case "mcp": + case "custom-tool": + return "dynamic_tool_call" as const; + default: + return "unknown" as const; + } +} + +function requestDetailFromPermissionRequest(request: PermissionRequest): string | undefined { + switch (request.kind) { + case "shell": + return trimToUndefined(String(request.fullCommandText ?? "")); + case "write": + return trimToUndefined(String(request.fileName ?? request.intention ?? "")); + case "read": + return trimToUndefined(String(request.path ?? request.intention ?? "")); + case "mcp": + return trimToUndefined(String(request.toolTitle ?? request.toolName ?? "")); + case "url": + return trimToUndefined(String(request.url ?? request.intention ?? "")); + case "custom-tool": + return trimToUndefined(String(request.toolName ?? request.toolDescription ?? "")); + default: + return undefined; + } +} + +function itemTypeFromToolEvent(event: Extract) { + return event.data.mcpToolName ? "mcp_tool_call" : "dynamic_tool_call"; +} + +function toolDetailFromEvent(data: { + readonly toolName?: string; + readonly mcpToolName?: string; + readonly mcpServerName?: string; +}) { + return trimToUndefined( + [data.mcpServerName, data.mcpToolName ?? data.toolName].filter(Boolean).join(" / "), + ); +} + +function withRefs(input: { + readonly threadId: ThreadId; + readonly eventId: EventId; + readonly createdAt: string; + readonly turnId: TurnId | undefined; + readonly itemId: string | undefined; + readonly requestId: string | undefined; + readonly rawMethod: string | undefined; + readonly rawPayload: unknown; +}): Omit { + const providerTurnId = input.turnId; + const providerItemId = toProviderItemId(input.itemId); + const providerRequestId = trimToUndefined(input.requestId); + return { + eventId: input.eventId, + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt, + ...(providerTurnId ? { turnId: providerTurnId } : {}), + ...(input.itemId ? { itemId: toRuntimeItemId(input.itemId) } : {}), + ...(input.requestId ? { requestId: toRuntimeRequestId(input.requestId) } : {}), + ...(providerTurnId || providerItemId || providerRequestId + ? { + providerRefs: { + ...(providerTurnId ? { providerTurnId } : {}), + ...(providerItemId ? { providerItemId } : {}), + ...(providerRequestId ? { providerRequestId } : {}), + }, + } + : {}), + raw: { + source: input.rawMethod ? "copilot.sdk.session-event" : "copilot.sdk.synthetic", + ...(input.rawMethod ? { method: input.rawMethod } : {}), + payload: input.rawPayload, + }, + }; +} + +function mapHistoryToTurns(threadId: ThreadId, events: ReadonlyArray): ProviderThreadSnapshot { + const turns: Array = []; + let current: { id: TurnId; items: Array } | undefined; + + for (const event of events) { + if (event.type === "assistant.turn_start") { + current = { + id: TurnId.makeUnsafe(event.data.turnId), + items: [event], + }; + turns.push(current); + continue; + } + + if (!current) { + continue; + } + + current.items.push(event); + if (event.type === "assistant.turn_end" || event.type === "abort" || event.type === "session.idle") { + current = undefined; + } + } + + return { + threadId, + turns: turns.map((turn) => ({ + id: turn.id, + items: turn.items, + })), + }; +} + +function makeSyntheticEvent( + threadId: ThreadId, + type: ProviderRuntimeEvent["type"], + payload: ProviderRuntimeEvent["payload"], + extra?: { + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }, +): ProviderRuntimeEvent { + return { + ...withRefs({ + threadId, + eventId: makeEventId("copilot-synthetic"), + createdAt: new Date().toISOString(), + turnId: extra?.turnId, + itemId: extra?.itemId, + requestId: extra?.requestId, + rawMethod: undefined, + rawPayload: payload, + }), + type, + payload, + } as ProviderRuntimeEvent; +} + +function resolveUserInputAnswer( + pending: PendingUserInputRequest, + answers: ProviderUserInputAnswers, +): CopilotUserInputResponse { + const direct = answers[USER_INPUT_QUESTION_ID]; + const candidate = + typeof direct === "string" + ? direct + : Object.values(answers).find((value): value is string => typeof value === "string"); + const answer = trimToUndefined(candidate) ?? ""; + return { + answer, + wasFreeform: !pending.request.choices?.includes(answer), + }; +} + +const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const nativeEventLogger = options?.nativeEventLogger; + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + + const emitRuntimeEvents = (events: ReadonlyArray) => + Effect.runPromise(Queue.offerAll(runtimeEventQueue, events).pipe(Effect.asVoid)).catch(() => undefined); + + const writeNativeEvent = (threadId: ThreadId, event: SessionEvent) => { + if (!nativeEventLogger) return Promise.resolve(); + return Effect.runPromise(nativeEventLogger.write(event, threadId)).catch(() => undefined); + }; + + const mapSessionEvent = ( + record: ActiveCopilotSession, + event: SessionEvent, + ): ReadonlyArray => { + const currentTurnId = record.currentTurnId; + const base = (input?: { + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }) => + withRefs({ + threadId: record.threadId, + eventId: EventId.makeUnsafe(event.id), + createdAt: event.timestamp, + turnId: input?.turnId ?? currentTurnId, + itemId: input?.itemId, + requestId: input?.requestId, + rawMethod: event.type, + rawPayload: event, + }); + + switch (event.type) { + case "session.start": + case "session.resume": + return [ + { + ...base(), + type: "session.started", + payload: { + message: + event.type === "session.resume" + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: event.data, + }, + }, + { + ...base(), + type: "thread.started", + payload: { + providerThreadId: + event.type === "session.start" ? event.data.sessionId : record.session.sessionId, + }, + }, + ]; + case "session.info": + return [ + { + ...base(), + type: "runtime.warning", + payload: { + message: event.data.message, + detail: event.data, + }, + }, + ]; + case "session.warning": + return [ + { + ...base(), + type: "runtime.warning", + payload: { + message: event.data.message, + detail: event.data, + }, + }, + ]; + case "session.error": + return [ + { + ...base(), + type: "runtime.error", + payload: { + message: event.data.message, + class: "provider_error", + detail: event.data, + }, + }, + { + ...base(), + type: "session.state.changed", + payload: { + state: "error", + reason: "session.error", + detail: event.data, + }, + }, + ]; + case "session.idle": + return [ + { + ...base(), + type: "session.state.changed", + payload: { + state: "ready", + reason: "session.idle", + }, + }, + { + ...base(), + type: "thread.state.changed", + payload: { + state: "idle", + detail: event.data, + }, + }, + ]; + case "session.title_changed": + return [ + { + ...base(), + type: "thread.metadata.updated", + payload: { + name: event.data.title, + metadata: event.data, + }, + }, + ]; + case "session.model_change": + return [ + { + ...base(), + type: "model.rerouted", + payload: { + fromModel: event.data.previousModel ?? "unknown", + toModel: event.data.newModel, + reason: "session.model_change", + }, + }, + ]; + case "session.plan_changed": + return [ + { + ...base(), + type: "turn.plan.updated", + payload: { + explanation: `Plan ${event.data.operation}d`, + plan: [], + }, + }, + ]; + case "session.workspace_file_changed": + return [ + { + ...base(), + type: "files.persisted", + payload: { + files: [ + { + filename: event.data.path, + fileId: event.data.path, + }, + ], + }, + }, + ]; + case "session.context_changed": + return [ + { + ...base(), + type: "thread.metadata.updated", + payload: { + metadata: event.data, + }, + }, + ]; + case "session.usage_info": + return [ + { + ...base(), + type: "thread.token-usage.updated", + payload: { + usage: event.data, + }, + }, + ]; + case "session.task_complete": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: toRuntimeTaskId(record.threadId) ?? RuntimeTaskId.makeUnsafe(record.threadId), + status: "completed", + ...(trimToUndefined(event.data.summary) ? { summary: event.data.summary } : {}), + }, + }, + ]; + case "assistant.turn_start": + return [ + { + ...base({ turnId: toTurnId(event.data.turnId) }), + type: "turn.started", + payload: record.model ? { model: record.model } : {}, + }, + { + ...base({ turnId: toTurnId(event.data.turnId) }), + type: "session.state.changed", + payload: { + state: "running", + reason: "assistant.turn_start", + }, + }, + ]; + case "assistant.reasoning": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.reasoning_delta": + return [ + { + ...base({ itemId: event.data.reasoningId }), + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.message": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + detail: trimToUndefined(event.data.content), + data: event.data, + }, + }, + ]; + case "assistant.message_delta": + return [ + { + ...base({ itemId: event.data.messageId }), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: event.data.deltaContent, + }, + }, + ]; + case "assistant.turn_end": + return [ + { + ...base({ turnId: toTurnId(event.data.turnId) }), + type: "turn.completed", + payload: { + state: "completed", + }, + }, + ]; + case "assistant.usage": + return [ + { + ...base(), + type: "turn.completed", + payload: { + state: "completed", + usage: event.data, + ...(event.data.cost !== undefined ? { totalCostUsd: event.data.cost } : {}), + ...(event.data.model ? { modelUsage: { model: event.data.model } } : {}), + }, + }, + { + ...base(), + type: "thread.token-usage.updated", + payload: { + usage: event.data, + }, + }, + ]; + case "abort": + return [ + { + ...base(), + type: "turn.aborted", + payload: { + reason: event.data.reason, + }, + }, + ]; + case "tool.execution_start": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.started", + payload: { + itemType: itemTypeFromToolEvent(event), + status: "inProgress", + title: event.data.toolName ?? "Tool call", + ...(toolDetailFromEvent(event.data) ? { detail: toolDetailFromEvent(event.data) } : {}), + data: event.data, + }, + }, + ]; + case "tool.execution_progress": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.progressMessage, + }, + }, + ]; + case "tool.execution_partial_result": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.progress", + payload: { + toolUseId: event.data.toolCallId, + summary: event.data.partialOutput, + }, + }, + ]; + case "tool.execution_complete": + return [ + { + ...base({ itemId: event.data.toolCallId }), + type: "item.completed", + payload: { + itemType: event.data.result?.contents?.some((content) => content.type === "terminal") + ? "command_execution" + : "dynamic_tool_call", + status: event.data.success ? "completed" : "failed", + title: "Tool call", + ...(trimToUndefined(event.data.result?.content) ? { detail: event.data.result?.content } : {}), + data: event.data, + }, + }, + ...(trimToUndefined(event.data.result?.content) + ? [ + { + ...base({ itemId: event.data.toolCallId }), + type: "tool.summary" as const, + payload: { + summary: event.data.result?.content ?? "", + precedingToolUseIds: [event.data.toolCallId], + }, + }, + ] + : []), + ]; + case "skill.invoked": + return [ + { + ...base(), + type: "task.progress", + payload: { + taskId: toRuntimeTaskId(event.data.name) ?? RuntimeTaskId.makeUnsafe(event.data.name), + description: `Invoked skill ${event.data.name}`, + }, + }, + ]; + case "subagent.started": + return [ + { + ...base(), + type: "task.started", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? RuntimeTaskId.makeUnsafe(event.data.toolCallId), + description: trimToUndefined(event.data.agentDescription), + taskType: "subagent", + }, + }, + ]; + case "subagent.completed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "completed", + ...(trimToUndefined(event.data.agentDisplayName) + ? { summary: event.data.agentDisplayName } + : {}), + }, + }, + ]; + case "subagent.failed": + return [ + { + ...base(), + type: "task.completed", + payload: { + taskId: + toRuntimeTaskId(event.data.toolCallId) ?? RuntimeTaskId.makeUnsafe(event.data.toolCallId), + status: "failed", + ...(trimToUndefined(event.data.error) ? { summary: event.data.error } : {}), + }, + }, + ]; + default: + return []; + } + }; + + const createSessionRecord = (input: { + readonly threadId: ThreadId; + readonly client: CopilotClient; + readonly session: CopilotSession; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly pendingApprovalResolvers: Map; + readonly pendingUserInputResolvers: Map; + readonly cwd: string | undefined; + readonly model: string | undefined; + }): ActiveCopilotSession => ({ + client: input.client, + session: input.session, + threadId: input.threadId, + createdAt: new Date().toISOString(), + runtimeMode: input.runtimeMode, + cwd: input.cwd, + model: input.model, + updatedAt: new Date().toISOString(), + lastError: undefined, + currentTurnId: undefined, + pendingTurnStarts: [], + pendingApprovalResolvers: input.pendingApprovalResolvers, + pendingUserInputResolvers: input.pendingUserInputResolvers, + unsubscribe: () => undefined, + }); + + const handleSessionEvent = (record: ActiveCopilotSession, event: SessionEvent) => { + record.updatedAt = event.timestamp; + if (event.type === "assistant.turn_start") { + const turnId = TurnId.makeUnsafe(event.data.turnId); + record.currentTurnId = turnId; + const pendingStart = record.pendingTurnStarts.shift(); + pendingStart?.(turnId); + } + if (event.type === "assistant.turn_end" || event.type === "abort" || event.type === "session.idle") { + record.currentTurnId = undefined; + } + if (event.type === "session.error") { + record.lastError = event.data.message; + } + if (event.type === "session.model_change") { + record.model = event.data.newModel; + } + + void writeNativeEvent(record.threadId, event); + const runtimeEvents = mapSessionEvent(record, event); + if (runtimeEvents.length > 0) { + void emitRuntimeEvents(runtimeEvents); + } + }; + + const getSessionRecord = (threadId: ThreadId) => { + const record = sessions.get(threadId); + if (!record) { + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + } + return Effect.succeed(record); + }; + + const stopRecord = async (record: ActiveCopilotSession) => { + record.unsubscribe(); + try { + await record.session.destroy(); + } catch { + // best effort + } + try { + await record.client.stop(); + } catch { + // best effort + } + for (const pending of record.pendingApprovalResolvers.values()) { + pending.resolve({ kind: "denied-interactively-by-user" }); + } + for (const pending of record.pendingUserInputResolvers.values()) { + pending.resolve({ answer: "", wasFreeform: true }); + } + sessions.delete(record.threadId); + }; + + const startSession: CopilotAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}', received '${input.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + return { + provider: PROVIDER, + status: "ready", + runtimeMode: existing.runtimeMode, + ...(existing.cwd ? { cwd: existing.cwd } : {}), + ...(existing.model ? { model: existing.model } : {}), + threadId: input.threadId, + resumeCursor: existing.session.sessionId, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + ...(existing.lastError ? { lastError: existing.lastError } : {}), + } satisfies ProviderSession; + } + + const cliPath = trimToUndefined(input.providerOptions?.copilot?.cliPath); + const configDir = trimToUndefined(input.providerOptions?.copilot?.configDir); + const resumeSessionId = extractResumeSessionId(input.resumeCursor); + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + logLevel: "error", + }); + const pendingApprovalResolvers = new Map(); + const pendingUserInputResolvers = new Map(); + + const onPermissionRequest = (request: PermissionRequest) => + new Promise((resolve) => { + const requestId = `copilot-approval-${randomUUID()}`; + pendingApprovalResolvers.set(requestId, { + requestType: requestTypeFromPermissionRequest(request), + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + input.threadId, + "request.opened", + { + requestType: requestTypeFromPermissionRequest(request), + ...(requestDetailFromPermissionRequest(request) + ? { detail: requestDetailFromPermissionRequest(request) } + : {}), + args: request, + }, + { requestId }, + ), + ]); + }); + + const onUserInputRequest = (request: CopilotUserInputRequest) => + new Promise((resolve) => { + const requestId = `copilot-user-input-${randomUUID()}`; + pendingUserInputResolvers.set(requestId, { + request, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + input.threadId, + "user-input.requested", + { + questions: [ + { + id: USER_INPUT_QUESTION_ID, + header: "GitHub Copilot", + question: request.question, + options: (request.choices ?? []).map((choice: string) => ({ + label: choice, + description: choice, + })), + }, + ], + }, + { requestId }, + ), + ]); + }); + + const session = yield* Effect.tryPromise({ + try: async () => { + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { + onPermissionRequest, + onUserInputRequest, + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + } + return client.createSession({ + onPermissionRequest, + onUserInputRequest, + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot session."), + cause, + }), + }); + + const record = createSessionRecord({ + threadId: input.threadId, + client, + session, + runtimeMode: input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + cwd: input.cwd, + model: input.model, + }); + const unsubscribe = session.on((event) => { + handleSessionEvent(record, event); + }); + record.unsubscribe = unsubscribe; + sessions.set(input.threadId, record); + + yield* Queue.offerAll(runtimeEventQueue, [ + makeSyntheticEvent(input.threadId, "session.started", { + message: resumeSessionId + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: { sessionId: session.sessionId }, + }), + makeSyntheticEvent(input.threadId, "session.configured", { + config: { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }, + }), + makeSyntheticEvent(input.threadId, "thread.started", { + providerThreadId: session.sessionId, + }), + makeSyntheticEvent(input.threadId, "session.state.changed", { + state: "ready", + reason: "session.started", + }), + ]); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + threadId: input.threadId, + resumeCursor: session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession; + }); + + const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const record = yield* getSessionRecord(input.threadId); + const attachments = (input.attachments ?? []) + .map((attachment) => { + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment, + }); + if (!attachmentPath) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + return { + type: "file" as const, + path: attachmentPath, + displayName: attachment.name, + }; + }); + + if (input.model && input.model !== record.model) { + yield* Effect.tryPromise({ + try: async () => { + await record.session.setModel(input.model!); + record.model = input.model; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.setModel", + detail: toMessage(cause, "Failed to switch GitHub Copilot model."), + cause, + }), + }); + } + + const fallbackTurnId = TurnId.makeUnsafe(`copilot-turn-${randomUUID()}`); + const turnIdPromise = new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(fallbackTurnId); + }, TURN_START_TIMEOUT_MS); + record.pendingTurnStarts.push((turnId) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(turnId); + }); + }); + + yield* Effect.tryPromise({ + try: () => + record.session.send({ + prompt: input.input ?? "", + ...(attachments.length > 0 ? { attachments } : {}), + mode: "immediate", + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: toMessage(cause, "Failed to send GitHub Copilot turn."), + cause, + }), + }); + + const turnId = yield* Effect.promise(() => turnIdPromise); + record.currentTurnId = turnId; + record.updatedAt = new Date().toISOString(); + + return { + threadId: input.threadId, + turnId, + resumeCursor: record.session.sessionId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CopilotAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: () => record.session.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: toMessage(cause, "Failed to interrupt GitHub Copilot turn."), + cause, + }), + }); + }); + + const respondToRequest: CopilotAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingApprovalResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.permission.respond", + detail: `Unknown pending GitHub Copilot approval request '${requestId}'.`, + }); + } + record.pendingApprovalResolvers.delete(requestId); + pending.resolve(approvalDecisionToPermissionResult(decision)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "request.resolved", + { + requestType: pending.requestType, + decision, + resolution: approvalDecisionToPermissionResult(decision), + }, + { requestId }, + ), + ); + }); + + const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + const pending = record.pendingUserInputResolvers.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.userInput.respond", + detail: `Unknown pending GitHub Copilot user-input request '${requestId}'.`, + }); + } + record.pendingUserInputResolvers.delete(requestId); + pending.resolve(resolveUserInputAnswer(pending, answers)); + yield* Queue.offer( + runtimeEventQueue, + makeSyntheticEvent( + threadId, + "user-input.resolved", + { + answers, + }, + { requestId }, + ), + ); + }); + + const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + yield* Effect.tryPromise({ + try: async () => { + await stopRecord(record); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to stop GitHub Copilot session."), + cause, + }), + }); + }); + + const listSessions: CopilotAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map( + (record) => + ({ + provider: PROVIDER, + status: record.currentTurnId ? "running" : "ready", + runtimeMode: record.runtimeMode, + threadId: record.threadId, + resumeCursor: record.session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(record.cwd ? { cwd: record.cwd } : {}), + ...(record.model ? { model: record.model } : {}), + ...(record.currentTurnId ? { activeTurnId: record.currentTurnId } : {}), + ...(record.lastError ? { lastError: record.lastError } : {}), + }) satisfies ProviderSession, + ), + ); + + const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CopilotAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const record = yield* getSessionRecord(threadId); + return yield* Effect.tryPromise({ + try: async () => { + const messages = await record.session.getMessages(); + return mapHistoryToTurns(threadId, messages); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.getMessages", + detail: toMessage(cause, "Failed to read GitHub Copilot thread history."), + cause, + }), + }); + }); + + const rollbackThread: CopilotAdapterShape["rollbackThread"] = (_threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: + "GitHub Copilot SDK does not expose a supported conversation rollback API for existing sessions.", + }), + ); + + const stopAll: CopilotAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + await Promise.all(Array.from(sessions.values()).map((record) => stopRecord(record))); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: ThreadId.makeUnsafe("_all"), + detail: toMessage(cause, "Failed to stop GitHub Copilot sessions."), + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CopilotAdapterShape; + }); + +export const CopilotAdapterLive = Layer.effect(CopilotAdapter, makeCopilotAdapter()); + +export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { + return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a50112a6..a16d3114 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,11 +28,31 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeCopilotAdapter: CopilotAdapterShape = { + provider: "copilot", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( ProviderAdapterRegistryLive, - Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(CopilotAdapter, fakeCopilotAdapter), + ), ), NodeServices.layer, ), @@ -43,9 +64,11 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); assert.equal(codex, fakeCodexAdapter); + const copilot = yield* registry.getByProvider("copilot"); + assert.equal(copilot, fakeCopilotAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "copilot"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c..256f1db8 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -26,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter]; + : [yield* CodexAdapter, yield* CopilotAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index e9362758..0be11d6a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -13,6 +13,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; +import { CopilotClient } from "@github/copilot-sdk"; import { Effect, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -20,6 +21,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const COPILOT_PROVIDER = "copilot" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -29,6 +31,11 @@ export interface CommandResult { readonly code: number; } +interface CopilotHealthProbeError { + readonly _tag: "CopilotHealthProbeError"; + readonly cause: unknown; +} + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -290,14 +297,89 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCopilotProviderStatus: Effect.Effect = Effect.gen( + function* () { + const checkedAt = new Date().toISOString(); + const probe = yield* Effect.tryPromise({ + try: async () => { + const client = new CopilotClient({ logLevel: "error" }); + try { + await client.start(); + const [status, authStatus] = await Promise.all([ + client.getStatus(), + client.getAuthStatus().catch(() => undefined), + ]); + return { status, authStatus }; + } finally { + await client.stop().catch(() => []); + } + }, + catch: (cause) => + ({ + _tag: "CopilotHealthProbeError", + cause, + }) satisfies CopilotHealthProbeError, + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(probe)) { + const error = probe.failure.cause; + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Failed to start GitHub Copilot CLI health check: ${error.message}.` + : "Failed to start GitHub Copilot CLI health check.", + }; + } + + if (Option.isNone(probe.success)) { + return { + provider: COPILOT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "GitHub Copilot CLI health check timed out while starting the SDK client.", + }; + } + + const authStatus: ServerProviderAuthStatus = + probe.success.value.authStatus?.isAuthenticated === true + ? "authenticated" + : probe.success.value.authStatus?.isAuthenticated === false + ? "unauthenticated" + : "unknown"; + const status: ServerProviderStatusState = + authStatus === "unauthenticated" ? "error" : authStatus === "unknown" ? "warning" : "ready"; + + return { + provider: COPILOT_PROVIDER, + status, + available: true, + authStatus, + checkedAt, + ...(probe.success.value.authStatus?.statusMessage + ? { message: probe.success.value.authStatus.statusMessage } + : probe.success.value.status?.version + ? { message: `GitHub Copilot CLI ${probe.success.value.status.version}` } + : {}), + } satisfies ServerProviderStatus; + }, +); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { const codexStatus = yield* checkCodexProviderStatus; + const copilotStatus = yield* checkCopilotProviderStatus; return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([codexStatus, copilotStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439..03a3b4e3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -25,7 +25,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "copilot") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/CopilotAdapter.ts b/apps/server/src/provider/Services/CopilotAdapter.ts new file mode 100644 index 00000000..4c8b9958 --- /dev/null +++ b/apps/server/src/provider/Services/CopilotAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CopilotAdapterShape extends ProviderAdapterShape { + readonly provider: "copilot"; +} + +export class CopilotAdapter extends ServiceMap.Service()( + "t3/provider/Services/CopilotAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55..eb6cb42e 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -57,8 +58,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const copilotAdapterLayer = makeCopilotAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(copilotAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 5ab5d3c9..362e1738 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -22,6 +22,12 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("tracks Copilot built-ins separately from Codex built-ins", () => { + expect(normalizeCustomModelSlugs(["gpt-5.4", "claude-sonnet-4.6", "custom/copilot"], "copilot")).toEqual([ + "custom/copilot", + ]); + }); }); describe("getAppModelOptions", () => { @@ -47,6 +53,15 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("returns the built-in Copilot options for the Copilot provider", () => { + const options = getAppModelOptions("copilot", []); + + expect(options.map((option) => option.slug)).toContain("gpt-5.4"); + expect(options.map((option) => option.slug)).toContain("claude-sonnet-4.6"); + expect(options.map((option) => option.slug)).toContain("gemini-3.1-pro"); + expect(options.map((option) => option.slug)).toContain("goldeneye"); + }); }); describe("resolveAppModelSelection", () => { @@ -58,6 +73,7 @@ describe("resolveAppModelSelection", () => { it("falls back to the provider default when no model is selected", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("copilot", [], "")).toBe("gpt-5.4"); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4..782cb186 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -28,6 +28,7 @@ const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -37,6 +38,12 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + copilotCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), @@ -45,6 +52,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customCopilotModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -108,6 +118,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customCopilotModels: normalizeCustomModelSlugs(settings.customCopilotModels, "copilot"), }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573f..634d49d7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -192,7 +192,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", - defaultModel: "gpt-5", + defaultModel: "gpt-5.4", scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -204,7 +204,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", - model: "gpt-5", + model: "gpt-5.4", interactionMode: "default", runtimeMode: "full-access", branch: "main", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46231694..284d004e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -163,6 +163,7 @@ import { import { ClaudeAI, CursorIcon, + GitHubIcon, Gemini, Icon, OpenAI, @@ -769,16 +770,18 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.session !== null), ); const selectedServiceTierSetting = settings.codexServiceTier; - const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedServiceTier = + selectedProvider === "codex" ? resolveAppServiceTier(selectedServiceTierSetting) : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = + selectedProvider === "copilot" ? settings.customCopilotModels : settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -805,6 +808,29 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + const selectedProviderOptionsForDispatch = useMemo(() => { + if (selectedProvider === "codex") { + const codexOptions = { + ...(settings.codexBinaryPath.trim().length > 0 ? { binaryPath: settings.codexBinaryPath.trim() } : {}), + ...(settings.codexHomePath.trim().length > 0 ? { homePath: settings.codexHomePath.trim() } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + } + + const copilotOptions = { + ...(settings.copilotCliPath.trim().length > 0 ? { cliPath: settings.copilotCliPath.trim() } : {}), + ...(settings.copilotConfigDir.trim().length > 0 + ? { configDir: settings.copilotConfigDir.trim() } + : {}), + }; + return Object.keys(copilotOptions).length > 0 ? { copilot: copilotOptions } : undefined; + }, [ + selectedProvider, + settings.codexBinaryPath, + settings.codexHomePath, + settings.copilotCliPath, + settings.copilotConfigDir, + ]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2482,7 +2508,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || + (activeProject.model as ModelSlug) || + DEFAULT_MODEL_BY_PROVIDER[selectedProvider]; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2564,6 +2592,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2839,6 +2870,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -2868,6 +2902,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, @@ -2899,7 +2934,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel || (activeThread.model as ModelSlug) || (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + DEFAULT_MODEL_BY_PROVIDER[selectedProvider]; sendInFlightRef.current = true; setSendPhase("sending-turn"); @@ -2938,6 +2973,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", @@ -2985,6 +3023,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, @@ -3000,7 +3039,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection( + provider, + provider === "copilot" ? settings.customCopilotModels : settings.customCodexModels, + model, + ), ); scheduleComposerFocus(); }, @@ -3010,6 +3053,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + settings.customCopilotModels, settings.customCodexModels, ], ); @@ -4025,11 +4069,15 @@ const ProviderHealthBanner = memo(function ProviderHealthBanner({ return (
- - - - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} - + + + + {status.provider === "codex" + ? "Codex provider status" + : status.provider === "copilot" + ? "GitHub Copilot provider status" + : `${status.provider} status`} + {status.message ?? defaultMessage} @@ -5194,14 +5242,17 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customCopilotModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + copilot: getAppModelOptions("copilot", settings.customCopilotModels), }; } const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, + copilot: GitHubIcon, claudeCode: ClaudeAI, cursor: CursorIcon, }; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3e..f806ffb2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "copilot" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a2..587ab775 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "copilot", + title: "GitHub Copilot", + description: "Save additional Copilot model slugs for the picker and `/model` command.", + placeholder: "your-copilot-model-slug", + example: "claude-sonnet-4.6", + }, ] as const; function getCustomModelsForProvider( @@ -62,8 +69,9 @@ function getCustomModelsForProvider( ) { switch (provider) { case "codex": - default: return settings.customCodexModels; + case "copilot": + return settings.customCopilotModels; } } @@ -73,16 +81,18 @@ function getDefaultCustomModelsForProvider( ) { switch (provider) { case "codex": - default: return defaults.customCodexModels; + case "copilot": + return defaults.customCopilotModels; } } function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { case "codex": - default: return { customCodexModels: models }; + case "copilot": + return { customCopilotModels: models }; } } @@ -96,6 +106,7 @@ function SettingsRouteView() { Record >({ codex: "", + copilot: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -103,6 +114,8 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const copilotCliPath = settings.copilotCliPath; + const copilotConfigDir = settings.copilotConfigDir; const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; @@ -300,6 +313,65 @@ function SettingsRouteView() {
+
+
+

GitHub Copilot CLI

+

+ These overrides apply to new Copilot sessions and let you target a custom CLI + install or config directory. +

+
+ +
+ + + + +
+

+ Binary source:{" "} + {copilotCliPath || "PATH"} +

+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 289d2c0c..de3c2d62 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -548,6 +548,7 @@ describe("PROVIDER_OPTIONS", () => { const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, + { value: "copilot", label: "GitHub Copilot", available: true }, { value: "claudeCode", label: "Claude Code", available: false }, { value: "cursor", label: "Cursor", available: false }, ]); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9519b7a4..173245ac 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,6 +18,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, + { value: "copilot", label: "GitHub Copilot", available: true }, { value: "claudeCode", label: "Claude Code", available: false }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c96653..a18843c6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -143,21 +143,26 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "copilot") { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const COPILOT_MODEL_SLUGS = new Set(getModelOptions("copilot").map((option) => option.slug)); function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "copilot") { return input.sessionProviderName; } + const normalizedCopilot = normalizeModelSlug(input.model, "copilot"); + if (normalizedCopilot && COPILOT_MODEL_SLUGS.has(normalizedCopilot)) { + return "copilot"; + } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; diff --git a/bun.lock b/bun.lock index e194b55c..2d056414 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot-sdk": "^0.1.8", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -333,6 +334,22 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@github/copilot": ["@github/copilot@0.0.420", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.420", "@github/copilot-darwin-x64": "0.0.420", "@github/copilot-linux-arm64": "0.0.420", "@github/copilot-linux-x64": "0.0.420", "@github/copilot-win32-arm64": "0.0.420", "@github/copilot-win32-x64": "0.0.420" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-UpPuSjxUxQ+j02WjZEFffWf0scLb23LvuGHzMFtaSsweR+P/BdbtDUI5ZDIA6T0tVyyt6+X1/vgfsJiRqd6jig=="], + + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.420", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-sj8Oxcf3oKDbeUotm2gtq5YU1lwCt3QIzbMZioFD/PMLOeqSX/wrecI+c0DDYXKofFhALb0+DxxnWgbEs0mnkQ=="], + + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@0.0.420", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-2acA93IqXz1uuz3TVUm0Y7BVrBr0MySh1kQa8LqMILhTsG0YHRMm8ybzTp2HA7Mi1tl5CjqMSk163kkS7OzfUA=="], + + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@0.0.420", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-h/IvEryTOYm1HzR2GNq8s2aDtN4lvT4MxldfZuS42CtWJDOfVG2jLLsoHWU1T3QV8j1++PmDgE//HX0JLpLMww=="], + + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@0.0.420", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-iL2NpZvXIDZ+3lw7sO2fo5T0nKmP5dZbU2gdYcv+SFBm/ONhCxIY5VRX4yN/9VkFaa9ePv5JzCnsl3vZINiDxg=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@0.1.30", "", { "dependencies": { "@github/copilot": "^0.0.420", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-Stg+h8xsPRR0TNGBQfd9laxhJfWZ6DsdpbowcKIZoyKxZvMAbjnY0zyDeOpewJbxWBTJVhBZb5okOq6iaPNMZw=="], + + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@0.0.420", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-Njlc2j9vYSBAL+lC6FIEhQ3C+VxO3xavwKnw0ecVRiNLcGLyPrTdzPfPQOmEjC63gpVCqLabikoDGv8fuLPA2w=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@0.0.420", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-rZlH35oNehAP2DvQbu4vQFVNeCh/1p3rUjafBYaEY0Nkhx7RmdrYBileL5U3PtRPPRsBPaq3Qp+pVIrGoCDLzQ=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], @@ -1867,7 +1884,7 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1975,6 +1992,8 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@github/copilot-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -2061,6 +2080,8 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09..666bb305 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -9,9 +9,12 @@ export const CodexModelOptions = Schema.Struct({ fastMode: Schema.optional(Schema.Boolean), }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const CopilotModelOptions = Schema.Struct({}); +export type CopilotModelOptions = typeof CopilotModelOptions.Type; export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + copilot: Schema.optional(CopilotModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +31,33 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + copilot: [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini (Preview)" }, + { slug: "gpt-5.1", name: "GPT-5.1" }, + { slug: "gpt-5-mini", name: "GPT-5 mini" }, + { slug: "gpt-4.1", name: "GPT-4.1" }, + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { slug: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { slug: "claude-opus-4.6", name: "Claude Opus 4.6" }, + { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 Fast (Preview)" }, + { slug: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro (Preview)" }, + { slug: "gemini-3-pro", name: "Gemini 3 Pro (Preview)" }, + { slug: "gemini-3-flash", name: "Gemini 3 Flash (Preview)" }, + { slug: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { slug: "grok-code-fast-1", name: "Grok Code Fast 1" }, + { slug: "goldeneye", name: "Goldeneye (Preview)" }, + { slug: "qwen2.5", name: "Qwen2.5" }, + { slug: "raptor-mini", name: "Raptor mini (Preview)" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +66,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + copilot: "gpt-5.4", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +77,33 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + copilot: { + "4.1": "gpt-4.1", + "5": "gpt-5.4", + "gpt-5": "gpt-5.4", + "5-mini": "gpt-5-mini", + "5.1": "gpt-5.1", + "5.1-codex": "gpt-5.1-codex", + "5.1-max": "gpt-5.1-codex-max", + "5.1-mini": "gpt-5.1-codex-mini", + "5.2": "gpt-5.2", + "5.2-codex": "gpt-5.2-codex", + "5.3": "gpt-5.3-codex", + "5.4": "gpt-5.4", + haiku: "claude-haiku-4.5", + sonnet: "claude-sonnet-4.6", + opus: "claude-opus-4.6", + gemini: "gemini-3-pro", + grok: "grok-code-fast-1", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + copilot: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + copilot: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 25a641ed..0e1d2060 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -186,6 +186,34 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts provider-scoped startup options in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-provider-options", + threadId: "thread-2", + message: { + messageId: "msg-provider-options", + role: "user", + text: "hello", + attachments: [], + }, + provider: "copilot", + model: "gpt-5.4", + providerOptions: { + copilot: { + cliPath: "/usr/local/bin/copilot", + configDir: "/tmp/.copilot", + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "copilot"); + assert.strictEqual(parsed.providerOptions?.copilot?.cliPath, "/usr/local/bin/copilot"); + assert.strictEqual(parsed.providerOptions?.copilot?.configDir, "/tmp/.copilot"); + }), +); + it.effect( "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () => diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa7bd827..3f982222 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Union([Schema.Literal("codex"), Schema.Literal("copilot")]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -45,6 +45,21 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); export type ProviderServiceTier = typeof ProviderServiceTier.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; +export const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; +export const CopilotProviderStartOptions = Schema.Struct({ + cliPath: Schema.optional(TrimmedNonEmptyString), + configDir: Schema.optional(TrimmedNonEmptyString), +}); +export type CopilotProviderStartOptions = typeof CopilotProviderStartOptions.Type; +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -366,6 +381,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -388,6 +404,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -668,6 +685,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b..c1f93b74 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -34,6 +34,26 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("accepts copilot-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-2", + provider: "copilot", + cwd: "/tmp/workspace", + model: "gpt-5.4", + runtimeMode: "full-access", + providerOptions: { + copilot: { + cliPath: "/usr/local/bin/copilot", + configDir: "/tmp/.copilot", + }, + }, + }); + + expect(parsed.provider).toBe("copilot"); + expect(parsed.providerOptions?.copilot?.cliPath).toBe("/usr/local/bin/copilot"); + expect(parsed.providerOptions?.copilot?.configDir).toBe("/tmp/.copilot"); + }); + it("rejects payloads without runtime mode", () => { expect(() => decodeProviderSessionStartInput({ @@ -61,4 +81,17 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); expect(parsed.modelOptions?.codex?.fastMode).toBe(true); }); + + it("accepts copilot model payloads without provider-specific options", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-2", + model: "gpt-5.4", + modelOptions: { + copilot: {}, + }, + }); + + expect(parsed.model).toBe("gpt-5.4"); + expect(parsed.modelOptions?.copilot).toEqual({}); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9ca7068a..0ada3b27 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -13,6 +13,7 @@ import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ProviderStartOptions, ProviderApprovalDecision, ProviderApprovalPolicy, ProviderInteractionMode, @@ -48,15 +49,6 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; -const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - homePath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), -}); - export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 903bb5da..84959b2f 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -20,6 +20,8 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.request", "codex.eventmsg", "codex.sdk.thread-event", + "copilot.sdk.session-event", + "copilot.sdk.synthetic", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c..0c17f395 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,6 +14,8 @@ describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("gpt-5", "copilot")).toBe("gpt-5.4"); + expect(normalizeModelSlug("sonnet", "copilot")).toBe("claude-sonnet-4.6"); }); it("returns null for empty or missing values", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb..1c058d76 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { From d0e520d0d9065531810c413b32ce772eae3f9d61 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 09:52:53 +0100 Subject: [PATCH 03/14] feat: refine Copilot model configuration Use live Copilot model metadata to validate session configuration, drive reasoning-effort support, and keep the web model picker aligned with server-reported capabilities. --- .../src/provider/Layers/CopilotAdapter.ts | 306 ++++++++++++++---- .../src/provider/Layers/ProviderHealth.ts | 22 +- apps/web/src/appSettings.test.ts | 36 ++- apps/web/src/appSettings.ts | 26 +- apps/web/src/components/ChatView.tsx | 163 +++++++--- apps/web/src/composerDraftStore.ts | 28 +- apps/web/src/routes/_chat.settings.tsx | 21 +- apps/web/src/store.ts | 25 +- packages/contracts/src/model.ts | 38 +-- packages/contracts/src/orchestration.test.ts | 8 +- packages/contracts/src/provider.test.ts | 18 +- packages/contracts/src/server.ts | 14 + packages/shared/src/model.test.ts | 1 - 13 files changed, 531 insertions(+), 175 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 85e27fbd..45a89ba4 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { + type CodexReasoningEffort, EventId, type ProviderApprovalDecision, ProviderItemId, @@ -16,6 +17,7 @@ import { } from "@t3tools/contracts"; import { CopilotClient, + type ModelInfo, type CopilotSession, type PermissionRequest, type PermissionRequestResult, @@ -74,12 +76,14 @@ interface PendingUserInputRequest { interface ActiveCopilotSession { readonly client: CopilotClient; - readonly session: CopilotSession; + session: CopilotSession; readonly threadId: ThreadId; readonly createdAt: string; readonly runtimeMode: ProviderSession["runtimeMode"]; cwd: string | undefined; + configDir: string | undefined; model: string | undefined; + reasoningEffort: CodexReasoningEffort | undefined; updatedAt: string; lastError: string | undefined; currentTurnId: TurnId | undefined; @@ -140,6 +144,24 @@ function trimToUndefined(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function mapSupportedModelsById(models: ReadonlyArray) { + return new Map(models.map((model) => [model.id, model])); +} + +function getCopilotReasoningEffort( + modelOptions: unknown, +) { + const record = asRecord(modelOptions); + const copilot = asRecord(record?.copilot); + const reasoningEffort = normalizeString(copilot?.reasoningEffort); + return reasoningEffort === "low" || + reasoningEffort === "medium" || + reasoningEffort === "high" || + reasoningEffort === "xhigh" + ? reasoningEffort + : undefined; +} + function extractResumeSessionId(resumeCursor: unknown): string | undefined { if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { return resumeCursor.trim(); @@ -755,6 +777,178 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } }; + const createInteractionHandlers = ( + threadId: ThreadId, + pendingApprovalResolvers: Map, + pendingUserInputResolvers: Map, + ) => { + const onPermissionRequest = (request: PermissionRequest) => + new Promise((resolve) => { + const requestId = `copilot-approval-${randomUUID()}`; + pendingApprovalResolvers.set(requestId, { + requestType: requestTypeFromPermissionRequest(request), + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "request.opened", + { + requestType: requestTypeFromPermissionRequest(request), + ...(requestDetailFromPermissionRequest(request) + ? { detail: requestDetailFromPermissionRequest(request) } + : {}), + args: request, + }, + { requestId }, + ), + ]); + }); + + const onUserInputRequest = (request: CopilotUserInputRequest) => + new Promise((resolve) => { + const requestId = `copilot-user-input-${randomUUID()}`; + pendingUserInputResolvers.set(requestId, { + request, + resolve, + }); + void emitRuntimeEvents([ + makeSyntheticEvent( + threadId, + "user-input.requested", + { + questions: [ + { + id: USER_INPUT_QUESTION_ID, + header: "GitHub Copilot", + question: request.question, + options: (request.choices ?? []).map((choice: string) => ({ + label: choice, + description: choice, + })), + }, + ], + }, + { requestId }, + ), + ]); + }); + + return { + onPermissionRequest, + onUserInputRequest, + }; + }; + + const validateSessionConfiguration = (input: { + readonly client: CopilotClient; + readonly threadId: ThreadId; + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }) => + Effect.gen(function* () { + if (!input.model && !input.reasoningEffort) { + return; + } + + const supportedModels = mapSupportedModelsById( + yield* Effect.tryPromise({ + try: () => input.client.listModels(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to load GitHub Copilot model metadata."), + cause, + }), + }), + ); + const selectedModel = input.model ? supportedModels.get(input.model) : undefined; + + if (input.model && !selectedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.model", + issue: `GitHub Copilot model '${input.model}' is not available in the current Copilot runtime.`, + }); + } + + if (!input.reasoningEffort) { + return; + } + + if (!selectedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: "GitHub Copilot reasoning effort requires an explicit supported model selection.", + }); + } + + const supportedReasoningEfforts = selectedModel.supportedReasoningEfforts ?? []; + if (supportedReasoningEfforts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${selectedModel.id}' does not support reasoning effort configuration.`, + }); + } + + if (!supportedReasoningEfforts.includes(input.reasoningEffort)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "session.reasoningEffort", + issue: `GitHub Copilot model '${selectedModel.id}' does not support reasoning effort '${input.reasoningEffort}'.`, + }); + } + }); + + const reconfigureSession = ( + record: ActiveCopilotSession, + input: { + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; + }, + ) => + Effect.tryPromise({ + try: async () => { + const sessionId = record.session.sessionId; + const previousSession = record.session; + const previousUnsubscribe = record.unsubscribe; + previousUnsubscribe(); + await previousSession.destroy(); + + const handlers = createInteractionHandlers( + record.threadId, + record.pendingApprovalResolvers, + record.pendingUserInputResolvers, + ); + const nextSession = await record.client.resumeSession(sessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), + ...(record.cwd ? { workingDirectory: record.cwd } : {}), + ...(record.configDir ? { configDir: record.configDir } : {}), + streaming: true, + }); + + record.session = nextSession; + record.model = input.model; + record.reasoningEffort = input.reasoningEffort; + record.updatedAt = new Date().toISOString(); + record.unsubscribe = nextSession.on((event) => { + handleSessionEvent(record, event); + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.reconfigure", + detail: toMessage(cause, "Failed to reconfigure GitHub Copilot session."), + cause, + }), + }); + const createSessionRecord = (input: { readonly threadId: ThreadId; readonly client: CopilotClient; @@ -763,7 +957,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => readonly pendingApprovalResolvers: Map; readonly pendingUserInputResolvers: Map; readonly cwd: string | undefined; + readonly configDir: string | undefined; readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; }): ActiveCopilotSession => ({ client: input.client, session: input.session, @@ -771,7 +967,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => createdAt: new Date().toISOString(), runtimeMode: input.runtimeMode, cwd: input.cwd, + configDir: input.configDir, model: input.model, + reasoningEffort: input.reasoningEffort, updatedAt: new Date().toISOString(), lastError: undefined, currentTurnId: undefined, @@ -871,75 +1069,36 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }); const pendingApprovalResolvers = new Map(); const pendingUserInputResolvers = new Map(); + const reasoningEffort = getCopilotReasoningEffort(input.modelOptions); + const handlers = createInteractionHandlers( + input.threadId, + pendingApprovalResolvers, + pendingUserInputResolvers, + ); - const onPermissionRequest = (request: PermissionRequest) => - new Promise((resolve) => { - const requestId = `copilot-approval-${randomUUID()}`; - pendingApprovalResolvers.set(requestId, { - requestType: requestTypeFromPermissionRequest(request), - resolve, - }); - void emitRuntimeEvents([ - makeSyntheticEvent( - input.threadId, - "request.opened", - { - requestType: requestTypeFromPermissionRequest(request), - ...(requestDetailFromPermissionRequest(request) - ? { detail: requestDetailFromPermissionRequest(request) } - : {}), - args: request, - }, - { requestId }, - ), - ]); - }); - - const onUserInputRequest = (request: CopilotUserInputRequest) => - new Promise((resolve) => { - const requestId = `copilot-user-input-${randomUUID()}`; - pendingUserInputResolvers.set(requestId, { - request, - resolve, - }); - void emitRuntimeEvents([ - makeSyntheticEvent( - input.threadId, - "user-input.requested", - { - questions: [ - { - id: USER_INPUT_QUESTION_ID, - header: "GitHub Copilot", - question: request.question, - options: (request.choices ?? []).map((choice: string) => ({ - label: choice, - description: choice, - })), - }, - ], - }, - { requestId }, - ), - ]); - }); + yield* validateSessionConfiguration({ + client, + threadId: input.threadId, + model: input.model, + reasoningEffort, + }); const session = yield* Effect.tryPromise({ try: async () => { if (resumeSessionId) { return client.resumeSession(resumeSessionId, { - onPermissionRequest, - onUserInputRequest, + ...handlers, ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), ...(input.cwd ? { workingDirectory: input.cwd } : {}), ...(configDir ? { configDir } : {}), streaming: true, }); } return client.createSession({ - onPermissionRequest, - onUserInputRequest, + ...handlers, ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), ...(input.cwd ? { workingDirectory: input.cwd } : {}), ...(configDir ? { configDir } : {}), streaming: true, @@ -962,7 +1121,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => pendingApprovalResolvers, pendingUserInputResolvers, cwd: input.cwd, + configDir, model: input.model, + reasoningEffort, }); const unsubscribe = session.on((event) => { handleSessionEvent(record, event); @@ -981,6 +1142,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => config: { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), ...(configDir ? { configDir } : {}), streaming: true, }, @@ -1010,6 +1172,14 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const record = yield* getSessionRecord(input.threadId); + const explicitReasoningEffort = getCopilotReasoningEffort(input.modelOptions); + const nextModel = input.model ?? record.model; + const nextReasoningEffort = + explicitReasoningEffort !== undefined + ? explicitReasoningEffort + : input.model && input.model !== record.model + ? undefined + : record.reasoningEffort; const attachments = (input.attachments ?? []) .map((attachment) => { const attachmentPath = resolveAttachmentPath({ @@ -1030,19 +1200,17 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }; }); - if (input.model && input.model !== record.model) { - yield* Effect.tryPromise({ - try: async () => { - await record.session.setModel(input.model!); - record.model = input.model; - }, - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.setModel", - detail: toMessage(cause, "Failed to switch GitHub Copilot model."), - cause, - }), + yield* validateSessionConfiguration({ + client: record.client, + threadId: input.threadId, + model: nextModel, + reasoningEffort: nextReasoningEffort, + }); + + if (nextModel !== record.model || nextReasoningEffort !== record.reasoningEffort) { + yield* reconfigureSession(record, { + model: nextModel, + reasoningEffort: nextReasoningEffort, }); } diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 0be11d6a..a9d6a4ba 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -10,10 +10,11 @@ */ import type { ServerProviderAuthStatus, + ServerProviderModel, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; import { Effect, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -36,6 +37,18 @@ interface CopilotHealthProbeError { readonly cause: unknown; } +function mapCopilotModel(model: ModelInfo): ServerProviderModel { + return { + id: model.id, + name: model.name, + supportsReasoningEffort: (model.supportedReasoningEfforts?.length ?? 0) > 0, + ...(model.supportedReasoningEfforts && model.supportedReasoningEfforts.length > 0 + ? { supportedReasoningEfforts: [...model.supportedReasoningEfforts] } + : {}), + ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), + } satisfies ServerProviderModel; +} + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -309,7 +322,9 @@ export const checkCopilotProviderStatus: Effect.Effect = E client.getStatus(), client.getAuthStatus().catch(() => undefined), ]); - return { status, authStatus }; + const models = + authStatus?.isAuthenticated === true ? await client.listModels().catch(() => undefined) : undefined; + return { status, authStatus, models }; } finally { await client.stop().catch(() => []); } @@ -362,6 +377,9 @@ export const checkCopilotProviderStatus: Effect.Effect = E available: true, authStatus, checkedAt, + ...(probe.success.value.models && probe.success.value.models.length > 0 + ? { models: probe.success.value.models.map(mapCopilotModel) } + : {}), ...(probe.success.value.authStatus?.statusMessage ? { message: probe.success.value.authStatus.statusMessage } : probe.success.value.status?.version diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 362e1738..ea564e7a 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -24,9 +24,9 @@ describe("normalizeCustomModelSlugs", () => { }); it("tracks Copilot built-ins separately from Codex built-ins", () => { - expect(normalizeCustomModelSlugs(["gpt-5.4", "claude-sonnet-4.6", "custom/copilot"], "copilot")).toEqual([ - "custom/copilot", - ]); + expect( + normalizeCustomModelSlugs(["gpt-5.3-codex", "claude-sonnet-4.6", "custom/copilot"], "copilot"), + ).toEqual(["custom/copilot"]); }); }); @@ -57,10 +57,21 @@ describe("getAppModelOptions", () => { it("returns the built-in Copilot options for the Copilot provider", () => { const options = getAppModelOptions("copilot", []); - expect(options.map((option) => option.slug)).toContain("gpt-5.4"); + expect(options.map((option) => option.slug)).toContain("gpt-5.3-codex"); expect(options.map((option) => option.slug)).toContain("claude-sonnet-4.6"); - expect(options.map((option) => option.slug)).toContain("gemini-3.1-pro"); - expect(options.map((option) => option.slug)).toContain("goldeneye"); + expect(options.map((option) => option.slug)).toContain("gemini-3-pro-preview"); + expect(options.map((option) => option.slug)).toContain("gpt-4.1"); + }); + + it("prefers live Copilot model lists when they are provided", () => { + const options = getAppModelOptions( + "copilot", + [], + "", + [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }], + ); + + expect(options.map((option) => option.slug)).toEqual(["gpt-5.3-codex"]); }); }); @@ -73,7 +84,18 @@ describe("resolveAppModelSelection", () => { it("falls back to the provider default when no model is selected", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); - expect(resolveAppModelSelection("copilot", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("copilot", [], "")).toBe("claude-sonnet-4.6"); + }); + + it("falls back to the first live Copilot model when runtime metadata is available", () => { + expect( + resolveAppModelSelection( + "copilot", + [], + "", + [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }], + ), + ).toBe("gpt-5.3-codex"); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 782cb186..a644adc5 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -63,6 +63,11 @@ export interface AppModelOption { isCustom: boolean; } +export interface BuiltInAppModelOption { + slug: string; + name: string; +} + export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { return serviceTier === "auto" ? null : serviceTier; } @@ -88,10 +93,14 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", + builtInOptions?: readonly BuiltInAppModelOption[], ): string[] { const normalizedModels: string[] = []; const seen = new Set(); - const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; + const builtInModelSlugs = + builtInOptions !== undefined + ? new Set(builtInOptions.map((option) => option.slug)) + : BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { const normalized = normalizeModelSlug(candidate, provider); @@ -126,15 +135,17 @@ export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], selectedModel?: string | null, + builtInOptions?: readonly BuiltInAppModelOption[], ): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ + const resolvedBuiltInOptions = builtInOptions ?? getModelOptions(provider); + const options: AppModelOption[] = resolvedBuiltInOptions.map(({ slug, name }) => ({ slug, name, isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); - for (const slug of normalizeCustomModelSlugs(customModels, provider)) { + for (const slug of normalizeCustomModelSlugs(customModels, provider, resolvedBuiltInOptions)) { if (seen.has(slug)) { continue; } @@ -163,8 +174,9 @@ export function resolveAppModelSelection( provider: ProviderKind, customModels: readonly string[], selectedModel: string | null | undefined, + builtInOptions?: readonly BuiltInAppModelOption[], ): string { - const options = getAppModelOptions(provider, customModels, selectedModel); + const options = getAppModelOptions(provider, customModels, selectedModel, builtInOptions); const trimmedSelectedModel = selectedModel?.trim(); if (trimmedSelectedModel) { const direct = options.find((option) => option.slug === trimmedSelectedModel); @@ -182,11 +194,12 @@ export function resolveAppModelSelection( const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); if (!normalizedSelectedModel) { - return getDefaultModel(provider); + return builtInOptions?.[0]?.slug ?? getDefaultModel(provider); } return ( options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? + builtInOptions?.[0]?.slug ?? getDefaultModel(provider) ); } @@ -196,9 +209,10 @@ export function getSlashModelOptions( customModels: readonly string[], query: string, selectedModel?: string | null, + builtInOptions?: readonly BuiltInAppModelOption[], ): AppModelOption[] { const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(provider, customModels, selectedModel); + const options = getAppModelOptions(provider, customModels, selectedModel, builtInOptions); if (!normalizedQuery) { return options; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 284d004e..b6cb3914 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -14,6 +14,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, + type ServerProviderModel, type ServerProviderStatus, type ProviderKind, type ThreadId, @@ -24,6 +25,7 @@ import { } from "@t3tools/contracts"; import { getDefaultModel, + getModelOptions, getDefaultReasoningEffort, getReasoningEffortOptions, normalizeModelSlug, @@ -204,6 +206,7 @@ import { resolveAppServiceTier, shouldShowFastTierIcon, type AppServiceTier, + type BuiltInAppModelOption, useAppSettings, } from "../appSettings"; import { @@ -237,6 +240,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_PROVIDER_MODELS: ServerProviderModel[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; @@ -742,6 +746,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const activeProvider = activeThread?.session?.provider ?? "codex"; + const activeProviderStatus = useMemo( + () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, + [activeProvider, providerStatuses], + ); useEffect(() => { if (!activeThread?.id) return; @@ -776,10 +787,38 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; const selectedServiceTier = selectedProvider === "codex" ? resolveAppServiceTier(selectedServiceTierSetting) : null; - const baseThreadModel = resolveModelSlugForProvider( - selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + const copilotProviderModels = + providerStatuses.find((status) => status.provider === "copilot")?.models ?? EMPTY_PROVIDER_MODELS; + const builtInModelOptionsByProvider = useMemo>>( + () => ({ + codex: getModelOptions("codex"), + copilot: + copilotProviderModels.length > 0 + ? copilotProviderModels.map((model) => ({ slug: model.id, name: model.name })) + : getModelOptions("copilot"), + }), + [copilotProviderModels], + ); + const defaultModelByProvider = useMemo>( + () => ({ + codex: getDefaultModel("codex"), + copilot: + builtInModelOptionsByProvider.copilot[0]?.slug ?? getDefaultModel("copilot"), + }), + [builtInModelOptionsByProvider], ); + const baseThreadModel = + selectedProvider === "copilot" + ? resolveAppModelSelection( + "copilot", + settings.customCopilotModels, + activeThread?.model ?? activeProject?.model ?? defaultModelByProvider.copilot, + builtInModelOptionsByProvider.copilot, + ) + : resolveModelSlugForProvider( + selectedProvider, + activeThread?.model ?? activeProject?.model ?? defaultModelByProvider[selectedProvider], + ); const customModelsForSelectedProvider = selectedProvider === "copilot" ? settings.customCopilotModels : settings.customCodexModels; const selectedModel = useMemo(() => { @@ -791,22 +830,46 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, customModelsForSelectedProvider, draftModel, + builtInModelOptionsByProvider[selectedProvider], ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); - const reasoningOptions = getReasoningEffortOptions(selectedProvider); + }, [ + baseThreadModel, + builtInModelOptionsByProvider, + composerDraft.model, + customModelsForSelectedProvider, + selectedProvider, + ]); + const selectedCopilotModelMetadata = + selectedProvider === "copilot" + ? copilotProviderModels.find((model) => model.id === selectedModel) ?? null + : null; + const reasoningOptions = + selectedProvider === "codex" + ? getReasoningEffortOptions("codex") + : (selectedCopilotModelMetadata?.supportedReasoningEfforts ?? []); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const defaultReasoningEffort = + selectedProvider === "codex" + ? getDefaultReasoningEffort("codex") + : (selectedCopilotModelMetadata?.defaultReasoningEffort ?? null); + const selectedEffort = + composerDraft.effort && reasoningOptions.includes(composerDraft.effort) + ? composerDraft.effort + : defaultReasoningEffort; const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider !== "codex") { - return undefined; + if (selectedProvider === "codex") { + const codexOptions = { + ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; } - const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + if (selectedProvider === "copilot" && supportsReasoningEffort && selectedEffort) { + return { copilot: { reasoningEffort: selectedEffort } }; + } + return undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); const selectedProviderOptionsForDispatch = useMemo(() => { if (selectedProvider === "codex") { @@ -833,8 +896,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings), - [settings], + () => getCustomModelOptionsByProvider(settings, builtInModelOptionsByProvider.copilot), + [builtInModelOptionsByProvider, settings], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; @@ -1176,7 +1239,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1268,12 +1330,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; - const activeProvider = activeThread?.session?.provider ?? "codex"; - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, - [activeProvider, providerStatuses], - ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -3043,12 +3099,15 @@ export default function ChatView({ threadId }: ChatViewProps) { provider, provider === "copilot" ? settings.customCopilotModels : settings.customCodexModels, model, + builtInModelOptionsByProvider[provider], ), + provider, ); scheduleComposerFocus(); }, [ activeThread, + builtInModelOptionsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, @@ -3609,15 +3668,18 @@ export default function ChatView({ threadId }: ChatViewProps) { onProviderModelChange={onProviderModelSelect} /> - {selectedProvider === "codex" && selectedEffort != null ? ( + {supportsReasoningEffort && selectedEffort != null ? ( <> - ) : null} @@ -5243,10 +5305,15 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; customCopilotModels: readonly string[]; -}): Record> { +}, builtInCopilotOptions: ReadonlyArray): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), - copilot: getAppModelOptions("copilot", settings.customCopilotModels), + copilot: getAppModelOptions( + "copilot", + settings.customCopilotModels, + undefined, + builtInCopilotOptions, + ), }; } @@ -5423,15 +5490,15 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); }); -const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { +const ModelTraitsPicker = memo(function ModelTraitsPicker(props: { effort: CodexReasoningEffort; - fastModeEnabled: boolean; + defaultEffort: CodexReasoningEffort | null; + fastModeEnabled?: boolean; options: ReadonlyArray; onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; + onFastModeChange?: (enabled: boolean) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); const reasoningLabelByOption: Record = { low: "Low", medium: "Medium", @@ -5440,7 +5507,7 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { }; const triggerLabel = [ reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), + ...(props.onFastModeChange && props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) .join(" · "); @@ -5479,24 +5546,28 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { {props.options.map((effort) => ( {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} + {effort === props.defaultEffort ? " (default)" : ""} ))} - - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.onFastModeChange ? ( + <> + + +
Fast Mode
+ { + props.onFastModeChange?.(value === "on"); + }} + > + off + on + +
+ + ) : null} ); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f806ffb2..bcf3836c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -123,7 +123,11 @@ interface ComposerDraftStoreState { clearDraftThread: (threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; - setModel: (threadId: ThreadId, model: string | null | undefined) => void; + setModel: ( + threadId: ThreadId, + model: string | null | undefined, + provider?: ProviderKind | null | undefined, + ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, @@ -211,6 +215,23 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { return value === "codex" || value === "copilot" ? value : null; } +function normalizeDraftModel( + value: string | null | undefined, + provider?: ProviderKind | null, +): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (!provider) { + return trimmed; + } + return normalizeModelSlug(trimmed, provider) ?? trimmed; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -805,13 +826,14 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setModel: (threadId, model) => { + setModel: (threadId, model, provider) => { if (threadId.length === 0) { return; } - const normalizedModel = normalizeModelSlug(model) ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; + const normalizedProvider = normalizeProviderKind(provider) ?? existing?.provider ?? null; + const normalizedModel = normalizeDraftModel(model, normalizedProvider); if (!existing && normalizedModel === null) { return state; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 587ab775..0c0dc935 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { ZapIcon } from "lucide-react"; @@ -111,6 +111,17 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const builtInModelOptionsByProvider = useMemo>>( + () => ({ + codex: getModelOptions("codex"), + copilot: + serverConfigQuery.data?.providers.find((status) => status.provider === "copilot")?.models?.map((model) => ({ + slug: model.id, + name: model.name, + })) ?? getModelOptions("copilot"), + }), + [serverConfigQuery.data?.providers], + ); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; @@ -147,7 +158,11 @@ function SettingsRouteView() { })); return; } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { + if ( + builtInModelOptionsByProvider[provider].some( + (option: { slug: string; name: string }) => option.slug === normalized, + ) + ) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "That model is already built in.", @@ -178,7 +193,7 @@ function SettingsRouteView() { ...existing, [provider]: null, })); - }, [customModelInputByProvider, settings, updateSettings]); + }, [builtInModelOptionsByProvider, customModelInputByProvider, settings, updateSettings]); const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a18843c6..4ae6e7a2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -151,6 +151,9 @@ function toLegacyProvider(providerName: string | null): ProviderKind { const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); const COPILOT_MODEL_SLUGS = new Set(getModelOptions("copilot").map((option) => option.slug)); +const AMBIGUOUS_PROVIDER_MODEL_SLUGS = new Set( + [...CODEX_MODEL_SLUGS].filter((slug) => COPILOT_MODEL_SLUGS.has(slug)), +); function inferProviderForThreadModel(input: { readonly model: string; @@ -160,7 +163,11 @@ function inferProviderForThreadModel(input: { return input.sessionProviderName; } const normalizedCopilot = normalizeModelSlug(input.model, "copilot"); - if (normalizedCopilot && COPILOT_MODEL_SLUGS.has(normalizedCopilot)) { + if ( + normalizedCopilot && + COPILOT_MODEL_SLUGS.has(normalizedCopilot) && + !AMBIGUOUS_PROVIDER_MODEL_SLUGS.has(normalizedCopilot) + ) { return "copilot"; } const normalizedCodex = normalizeModelSlug(input.model, "codex"); @@ -214,18 +221,20 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); + const inferredProvider = inferProviderForThreadModel({ + model: thread.model, + sessionProviderName: thread.session?.providerName ?? null, + }); + const normalizedKnownProviderModel = normalizeModelSlug(thread.model, inferredProvider); return { id: thread.id, codexThreadId: null, projectId: thread.projectId, title: thread.title, - model: resolveModelSlugForProvider( - inferProviderForThreadModel({ - model: thread.model, - sessionProviderName: thread.session?.providerName ?? null, - }), - thread.model, - ), + model: + thread.session?.providerName === "copilot" + ? (normalizedKnownProviderModel ?? thread.model) + : resolveModelSlugForProvider(inferredProvider, thread.model), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, session: thread.session diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 666bb305..75f62617 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -9,7 +9,9 @@ export const CodexModelOptions = Schema.Struct({ fastMode: Schema.optional(Schema.Boolean), }); export type CodexModelOptions = typeof CodexModelOptions.Type; -export const CopilotModelOptions = Schema.Struct({}); +export const CopilotModelOptions = Schema.Struct({ + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), +}); export type CopilotModelOptions = typeof CopilotModelOptions.Type; export const ProviderModelOptions = Schema.Struct({ @@ -32,31 +34,23 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2", name: "GPT-5.2" }, ], copilot: [ - { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { slug: "claude-opus-4.6", name: "Claude Opus 4.6" }, + { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 (fast mode)" }, + { slug: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { slug: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { slug: "gemini-3-pro-preview", name: "Gemini 3 Pro (Preview)" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex" }, - { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini (Preview)" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, { slug: "gpt-5.1", name: "GPT-5.1" }, { slug: "gpt-5-mini", name: "GPT-5 mini" }, { slug: "gpt-4.1", name: "GPT-4.1" }, - { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, - { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, - { slug: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { slug: "claude-opus-4.6", name: "Claude Opus 4.6" }, - { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 Fast (Preview)" }, - { slug: "claude-opus-4.5", name: "Claude Opus 4.5" }, - { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, - { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro (Preview)" }, - { slug: "gemini-3-pro", name: "Gemini 3 Pro (Preview)" }, - { slug: "gemini-3-flash", name: "Gemini 3 Flash (Preview)" }, - { slug: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, - { slug: "grok-code-fast-1", name: "Grok Code Fast 1" }, - { slug: "goldeneye", name: "Goldeneye (Preview)" }, - { slug: "qwen2.5", name: "Qwen2.5" }, - { slug: "raptor-mini", name: "Raptor mini (Preview)" }, ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -66,7 +60,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", - copilot: "gpt-5.4", + copilot: "claude-sonnet-4.6", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -79,8 +73,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { }, copilot: { "4.1": "gpt-4.1", - "5": "gpt-5.4", - "gpt-5": "gpt-5.4", "5-mini": "gpt-5-mini", "5.1": "gpt-5.1", "5.1-codex": "gpt-5.1-codex", @@ -89,12 +81,10 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.2": "gpt-5.2", "5.2-codex": "gpt-5.2-codex", "5.3": "gpt-5.3-codex", - "5.4": "gpt-5.4", haiku: "claude-haiku-4.5", sonnet: "claude-sonnet-4.6", opus: "claude-opus-4.6", - gemini: "gemini-3-pro", - grok: "grok-code-fast-1", + gemini: "gemini-3-pro-preview", }, } as const satisfies Record>; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 0e1d2060..11d2b742 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -199,7 +199,12 @@ it.effect("accepts provider-scoped startup options in thread.turn.start", () => attachments: [], }, provider: "copilot", - model: "gpt-5.4", + model: "gpt-5.3-codex", + modelOptions: { + copilot: { + reasoningEffort: "high", + }, + }, providerOptions: { copilot: { cliPath: "/usr/local/bin/copilot", @@ -209,6 +214,7 @@ it.effect("accepts provider-scoped startup options in thread.turn.start", () => createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.provider, "copilot"); + assert.strictEqual(parsed.modelOptions?.copilot?.reasoningEffort, "high"); assert.strictEqual(parsed.providerOptions?.copilot?.cliPath, "/usr/local/bin/copilot"); assert.strictEqual(parsed.providerOptions?.copilot?.configDir, "/tmp/.copilot"); }), diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index c1f93b74..130c06bc 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -39,8 +39,13 @@ describe("ProviderSessionStartInput", () => { threadId: "thread-2", provider: "copilot", cwd: "/tmp/workspace", - model: "gpt-5.4", + model: "gpt-5.3-codex", runtimeMode: "full-access", + modelOptions: { + copilot: { + reasoningEffort: "high", + }, + }, providerOptions: { copilot: { cliPath: "/usr/local/bin/copilot", @@ -50,6 +55,7 @@ describe("ProviderSessionStartInput", () => { }); expect(parsed.provider).toBe("copilot"); + expect(parsed.modelOptions?.copilot?.reasoningEffort).toBe("high"); expect(parsed.providerOptions?.copilot?.cliPath).toBe("/usr/local/bin/copilot"); expect(parsed.providerOptions?.copilot?.configDir).toBe("/tmp/.copilot"); }); @@ -85,13 +91,15 @@ describe("ProviderSendTurnInput", () => { it("accepts copilot model payloads without provider-specific options", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-2", - model: "gpt-5.4", + model: "gpt-5.3-codex", modelOptions: { - copilot: {}, + copilot: { + reasoningEffort: "medium", + }, }, }); - expect(parsed.model).toBe("gpt-5.4"); - expect(parsed.modelOptions?.copilot).toEqual({}); + expect(parsed.model).toBe("gpt-5.3-codex"); + expect(parsed.modelOptions?.copilot?.reasoningEffort).toBe("medium"); }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1..129b3712 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; +import { CODEX_REASONING_EFFORT_OPTIONS } from "./model"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -33,6 +34,18 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; +export const ServerProviderModelReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); +export type ServerProviderModelReasoningEffort = typeof ServerProviderModelReasoningEffort.Type; + +export const ServerProviderModel = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + supportsReasoningEffort: Schema.Boolean, + supportedReasoningEfforts: Schema.optional(Schema.Array(ServerProviderModelReasoningEffort)), + defaultReasoningEffort: Schema.optional(ServerProviderModelReasoningEffort), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + export const ServerProviderStatus = Schema.Struct({ provider: ProviderKind, status: ServerProviderStatusState, @@ -40,6 +53,7 @@ export const ServerProviderStatus = Schema.Struct({ authStatus: ServerProviderAuthStatus, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + models: Schema.optional(Schema.Array(ServerProviderModel)), }); export type ServerProviderStatus = typeof ServerProviderStatus.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 0c17f395..37650334 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,7 +14,6 @@ describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); - expect(normalizeModelSlug("gpt-5", "copilot")).toBe("gpt-5.4"); expect(normalizeModelSlug("sonnet", "copilot")).toBe("claude-sonnet-4.6"); }); From 01328e087ecfadab37dbd39fadb039f1cb91c836 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 10:08:50 +0100 Subject: [PATCH 04/14] fix: improve Copilot CLI startup and message finalization Resolve a bundled Copilot CLI by default, update the Copilot SDK dependency, and avoid duplicating assistant message text when providers send both deltas and completed-message fallbacks. --- apps/server/package.json | 3 +- .../Layers/ProviderRuntimeIngestion.test.ts | 4 +++ .../Layers/ProviderRuntimeIngestion.ts | 28 ++++++++++++++-- .../src/provider/Layers/CopilotAdapter.ts | 4 ++- .../src/provider/Layers/ProviderHealth.ts | 7 +++- .../src/provider/Layers/copilotCliPath.ts | 22 +++++++++++++ apps/web/src/appSettings.test.ts | 1 + bun.lock | 33 ++++++++++++++----- packages/contracts/src/model.ts | 2 ++ 9 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/provider/Layers/copilotCliPath.ts diff --git a/apps/server/package.json b/apps/server/package.json index f35779c3..f7cf5b14 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,7 +22,8 @@ "test": "vitest run" }, "dependencies": { - "@github/copilot-sdk": "^0.1.8", + "@github/copilot": "1.0.2", + "@github/copilot-sdk": "0.1.31-unstable.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9ab46e29..23b14e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -531,6 +531,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "assistant_message", status: "completed", + detail: "hello live", }, }); @@ -726,6 +727,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "assistant_message", status: "completed", + detail: "buffer me", }, }); @@ -818,6 +820,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "assistant_message", status: "completed", + detail: "hello live", }, }); @@ -878,6 +881,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "assistant_message", status: "completed", + detail: oversizedText, }, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 04f6d852..bc0804b1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -501,6 +501,12 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(""), }); + const assistantMessageSawDeltaByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(false), + }); + const bufferedProposedPlanById = yield* Cache.make({ capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, @@ -592,6 +598,18 @@ const make = Effect.gen(function* () { const clearBufferedAssistantText = (messageId: MessageId) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + const markAssistantMessageSawDelta = (messageId: MessageId) => + Cache.set(assistantMessageSawDeltaByMessageId, messageId, true); + + const takeAssistantMessageSawDelta = (messageId: MessageId) => + Cache.getOption(assistantMessageSawDeltaByMessageId, messageId).pipe( + Effect.flatMap((existing) => + Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId).pipe( + Effect.as(Option.getOrElse(existing, () => false)), + ), + ), + ); + const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( Effect.flatMap((existingEntry) => { @@ -615,7 +633,11 @@ const make = Effect.gen(function* () { const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); - const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); + const clearAssistantMessageState = (messageId: MessageId) => + Effect.all([ + clearBufferedAssistantText(messageId), + Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId), + ]).pipe(Effect.asVoid); const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; @@ -629,10 +651,11 @@ const make = Effect.gen(function* () { }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const sawDelta = yield* takeAssistantMessageSawDelta(input.messageId); const text = bufferedText.length > 0 ? bufferedText - : (input.fallbackText?.trim().length ?? 0) > 0 + : !sawDelta && (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; @@ -881,6 +904,7 @@ const make = Effect.gen(function* () { if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } + yield* markAssistantMessageSawDelta(assistantMessageId); const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 45a89ba4..194b6a61 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -34,6 +34,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import type { ProviderThreadSnapshot, @@ -1059,7 +1060,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } satisfies ProviderSession; } - const cliPath = trimToUndefined(input.providerOptions?.copilot?.cliPath); + const cliPath = + trimToUndefined(input.providerOptions?.copilot?.cliPath) ?? resolveBundledCopilotCliPath(); const configDir = trimToUndefined(input.providerOptions?.copilot?.configDir); const resumeSessionId = extractResumeSessionId(input.resumeCursor); const client = new CopilotClient({ diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index a9d6a4ba..f60c088b 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -19,6 +19,7 @@ import { Effect, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; +import { resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; @@ -315,7 +316,11 @@ export const checkCopilotProviderStatus: Effect.Effect = E const checkedAt = new Date().toISOString(); const probe = yield* Effect.tryPromise({ try: async () => { - const client = new CopilotClient({ logLevel: "error" }); + const cliPath = resolveBundledCopilotCliPath(); + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }); try { await client.start(); const [status, authStatus] = await Promise.all([ diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts new file mode 100644 index 00000000..28b0420d --- /dev/null +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -0,0 +1,22 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); + +export function resolveBundledCopilotCliPath(): string | undefined { + const localDependencyCliPath = join(CURRENT_DIR, "../../../node_modules/@github/copilot/npm-loader.js"); + if (existsSync(localDependencyCliPath)) { + return localDependencyCliPath; + } + + try { + const sdkEntrypoint = require.resolve("@github/copilot-sdk"); + const cliPath = join(dirname(sdkEntrypoint), "..", "npm-loader.js"); + return existsSync(cliPath) ? cliPath : undefined; + } catch { + return undefined; + } +} diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index ea564e7a..aad4494a 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -57,6 +57,7 @@ describe("getAppModelOptions", () => { it("returns the built-in Copilot options for the Copilot provider", () => { const options = getAppModelOptions("copilot", []); + expect(options.map((option) => option.slug)).toContain("gpt-5.4"); expect(options.map((option) => option.slug)).toContain("gpt-5.3-codex"); expect(options.map((option) => option.slug)).toContain("claude-sonnet-4.6"); expect(options.map((option) => option.slug)).toContain("gemini-3-pro-preview"); diff --git a/bun.lock b/bun.lock index 2d056414..c6be702f 100644 --- a/bun.lock +++ b/bun.lock @@ -50,7 +50,8 @@ "dependencies": { "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@github/copilot-sdk": "^0.1.8", + "@github/copilot": "1.0.2", + "@github/copilot-sdk": "0.1.31-unstable.0", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -334,21 +335,21 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@github/copilot": ["@github/copilot@0.0.420", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.420", "@github/copilot-darwin-x64": "0.0.420", "@github/copilot-linux-arm64": "0.0.420", "@github/copilot-linux-x64": "0.0.420", "@github/copilot-win32-arm64": "0.0.420", "@github/copilot-win32-x64": "0.0.420" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-UpPuSjxUxQ+j02WjZEFffWf0scLb23LvuGHzMFtaSsweR+P/BdbtDUI5ZDIA6T0tVyyt6+X1/vgfsJiRqd6jig=="], + "@github/copilot": ["@github/copilot@1.0.2", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.2", "@github/copilot-darwin-x64": "1.0.2", "@github/copilot-linux-arm64": "1.0.2", "@github/copilot-linux-x64": "1.0.2", "@github/copilot-win32-arm64": "1.0.2", "@github/copilot-win32-x64": "1.0.2" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww=="], - "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.420", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-sj8Oxcf3oKDbeUotm2gtq5YU1lwCt3QIzbMZioFD/PMLOeqSX/wrecI+c0DDYXKofFhALb0+DxxnWgbEs0mnkQ=="], + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw=="], - "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@0.0.420", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-2acA93IqXz1uuz3TVUm0Y7BVrBr0MySh1kQa8LqMILhTsG0YHRMm8ybzTp2HA7Mi1tl5CjqMSk163kkS7OzfUA=="], + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg=="], - "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@0.0.420", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-h/IvEryTOYm1HzR2GNq8s2aDtN4lvT4MxldfZuS42CtWJDOfVG2jLLsoHWU1T3QV8j1++PmDgE//HX0JLpLMww=="], + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.2", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q=="], - "@github/copilot-linux-x64": ["@github/copilot-linux-x64@0.0.420", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-iL2NpZvXIDZ+3lw7sO2fo5T0nKmP5dZbU2gdYcv+SFBm/ONhCxIY5VRX4yN/9VkFaa9ePv5JzCnsl3vZINiDxg=="], + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.2", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw=="], - "@github/copilot-sdk": ["@github/copilot-sdk@0.1.30", "", { "dependencies": { "@github/copilot": "^0.0.420", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-Stg+h8xsPRR0TNGBQfd9laxhJfWZ6DsdpbowcKIZoyKxZvMAbjnY0zyDeOpewJbxWBTJVhBZb5okOq6iaPNMZw=="], + "@github/copilot-sdk": ["@github/copilot-sdk@0.1.31-unstable.0", "", { "dependencies": { "@github/copilot": "^0.0.421", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-vQUH+dfY1n9rkmpkueKU6jnW1SgQWw8hENwPTfQLkAThWaYwjerVLq3ArW+Lhq5gAddzWny4bOSQ7tBI8KF8OA=="], - "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@0.0.420", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-Njlc2j9vYSBAL+lC6FIEhQ3C+VxO3xavwKnw0ecVRiNLcGLyPrTdzPfPQOmEjC63gpVCqLabikoDGv8fuLPA2w=="], + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.2", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA=="], - "@github/copilot-win32-x64": ["@github/copilot-win32-x64@0.0.420", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-rZlH35oNehAP2DvQbu4vQFVNeCh/1p3rUjafBYaEY0Nkhx7RmdrYBileL5U3PtRPPRsBPaq3Qp+pVIrGoCDLzQ=="], + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.2", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ=="], "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], @@ -1992,6 +1993,8 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@github/copilot-sdk/@github/copilot": ["@github/copilot@0.0.421", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.421", "@github/copilot-darwin-x64": "0.0.421", "@github/copilot-linux-arm64": "0.0.421", "@github/copilot-linux-x64": "0.0.421", "@github/copilot-win32-arm64": "0.0.421", "@github/copilot-win32-x64": "0.0.421" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-nDUt9f5al7IgBOTc7AwLpqvaX61VsRDYDQ9D5iR0QQzHo4pgDcyOXIjXUQUKsJwObXHfh6qR+Jm1vnlbw5cacg=="], + "@github/copilot-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2102,6 +2105,18 @@ "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "@github/copilot-sdk/@github/copilot/@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.421", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-S4plFsxH7W8X1gEkGNcfyKykIji4mNv8BP/GpPs2Ad84qWoJpZzfZsjrjF0BQ8mvFObWp6Ft2SZOnJzFZW1Ftw=="], + + "@github/copilot-sdk/@github/copilot/@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@0.0.421", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-h+Dbfq8ByAielLYIeJbjkN/9Abs6AKHFi+XuuzEy4YA9jOA42uKMFsWYwaoYH8ZLK9Y+4wagYI9UewVPnyIWPA=="], + + "@github/copilot-sdk/@github/copilot/@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@0.0.421", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-cxlqDRR/wKfbdzd456N2h7sZOZY069wU2ycSYSmo7cC75U5DyhMGYAZwyAhvQ7UKmS5gJC/wgSgye0njuK22Xg=="], + + "@github/copilot-sdk/@github/copilot/@github/copilot-linux-x64": ["@github/copilot-linux-x64@0.0.421", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-7np5b6EEemJ3U3jnl92buJ88nlpqOAIrLaJxx3pJGrP9SVFMBD/6EAlfIQ5m5QTfs+/vIuTKWBrq1wpFVZZUcQ=="], + + "@github/copilot-sdk/@github/copilot/@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@0.0.421", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-T6qCqOnijD5pmC0ytVsahX3bpDnXtLTgo9xFGo/BGaPEvX02ePkzcRZkfkOclkzc8QlkVji6KqZYB+qMZTliwg=="], + + "@github/copilot-sdk/@github/copilot/@github/copilot-win32-x64": ["@github/copilot-win32-x64@0.0.421", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-KDfy3wsRQFIcOQDdd5Mblvh+DWRq+UGbTQ34wyW36ws1BsdWkV++gk9bTkeJRsPbQ51wsJ0V/jRKEZv4uK5dTA=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@tailwindcss/vite/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 75f62617..af7fb956 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -34,6 +34,7 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2", name: "GPT-5.2" }, ], copilot: [ + { slug: "gpt-5.4", name: "GPT-5.4" }, { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, @@ -73,6 +74,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { }, copilot: { "4.1": "gpt-4.1", + "5.4": "gpt-5.4", "5-mini": "gpt-5-mini", "5.1": "gpt-5.1", "5.1-codex": "gpt-5.1-codex", From 8c2de1be7a4e3fc7e0b2e2ea8158a2be3fb5a1a3 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 10:45:32 +0100 Subject: [PATCH 05/14] fix: harden Copilot runtime and checkpoint fallbacks Skip missing workspace roots when starting sessions or capturing checkpoints, avoid duplicate or empty assistant message finalization, and add adapter coverage through an injectable Copilot client factory. --- .../Layers/CheckpointDiffQuery.ts | 6 +- apps/server/src/checkpointing/Utils.ts | 20 ++ .../Layers/CheckpointReactor.test.ts | 60 +++++- .../orchestration/Layers/CheckpointReactor.ts | 96 +++++---- .../Layers/ProviderCommandReactor.test.ts | 48 ++++- .../Layers/ProviderCommandReactor.ts | 38 +++- .../Layers/ProviderRuntimeIngestion.test.ts | 186 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 24 +++ .../provider/Layers/CopilotAdapter.test.ts | 104 ++++++++++ .../src/provider/Layers/CopilotAdapter.ts | 49 ++++- 10 files changed, 561 insertions(+), 70 deletions(-) create mode 100644 apps/server/src/provider/Layers/CopilotAdapter.test.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 5cf26c86..5e06b162 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -8,7 +8,7 @@ import { Effect, Layer, Schema } from "effect"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts"; +import { checkpointRefForThreadTurn, resolveExistingThreadWorkspaceCwd } from "../Utils.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery, @@ -62,14 +62,14 @@ const make = Effect.gen(function* () { }); } - const workspaceCwd = resolveThreadWorkspaceCwd({ + const workspaceCwd = resolveExistingThreadWorkspaceCwd({ thread, projects: snapshot.projects, }); if (!workspaceCwd) { return yield* new CheckpointInvariantError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, + detail: `Workspace path is missing or unavailable for thread '${input.threadId}' when computing turn diff.`, }); } diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 3cd92f85..9554b059 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; + import { Encoding } from "effect"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; @@ -26,3 +28,21 @@ export function resolveThreadWorkspaceCwd(input: { return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; } + +export function resolveExistingThreadWorkspaceCwd(input: { + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + }; + readonly projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly workspaceRoot: string; + }>; +}): string | undefined { + const resolvedCwd = resolveThreadWorkspaceCwd(input); + if (!resolvedCwd) { + return undefined; + } + + return existsSync(resolvedCwd) ? resolvedCwd : undefined; +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index d675c85f..af35af7c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -609,6 +609,47 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); + it("skips invalid project workspace roots and falls back to provider session cwd", async () => { + const missingWorkspaceRoot = path.join( + os.tmpdir(), + `t3-missing-workspace-${crypto.randomUUID()}`, + ); + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + projectWorkspaceRoot: missingWorkspaceRoot, + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-invalid-project-workspace"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: MessageId.makeUnsafe("message-user-invalid-project-workspace"), + role: "user", + text: "start turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); + + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + expect( + gitShowFileAtRef( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + "README.md", + ), + ).toBe("v1\n"); + }); + it("ignores non-v2 checkpoint.captured runtime events", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -651,7 +692,7 @@ describe("CheckpointReactor", () => { ); }); - it("continues processing runtime events after a single checkpoint runtime failure", async () => { + it("falls back from an invalid provider session cwd and keeps processing runtime events", async () => { const nonRepositorySessionCwd = fs.mkdtempSync( path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), ); @@ -692,22 +733,29 @@ describe("CheckpointReactor", () => { payload: { state: "completed" }, }); + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ - type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-after-invalid-session-cwd"), provider: "codex", createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-after-runtime-failure"), + turnId: asTurnId("turn-after-invalid-session-cwd"), + payload: { state: "completed" }, }); await waitForGitRefExists( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), ); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), ).toBe(true); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 07ffac32..5bc42421 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -12,6 +12,7 @@ import { Cause, Effect, Layer, Option, Queue, Stream } from "effect"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { checkpointRefForThreadTurn, + resolveExistingThreadWorkspaceCwd, resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; @@ -144,6 +145,46 @@ const make = Effect.gen(function* () { return Option.none(); }); + const resolveUsableWorkspaceCwdForThread = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | null; + readonly context: "checkpoint capture" | "checkpoint pre-turn capture"; + }) { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === input.threadId); + if (!thread) { + return { thread: undefined, cwd: undefined as string | undefined }; + } + + const threadWorkspaceCwd = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); + const existingThreadWorkspaceCwd = resolveExistingThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); + if (threadWorkspaceCwd && !existingThreadWorkspaceCwd) { + yield* Effect.logWarning(`${input.context} ignoring missing thread workspace cwd`, { + threadId: input.threadId, + ...(input.turnId ? { turnId: input.turnId } : {}), + cwd: threadWorkspaceCwd, + }); + } + + if (existingThreadWorkspaceCwd) { + return { thread, cwd: existingThreadWorkspaceCwd }; + } + + const sessionRuntime = yield* resolveSessionRuntimeForThread(input.threadId); + const sessionCwd = Option.match(sessionRuntime, { + onNone: () => undefined, + onSome: (runtime) => runtime.cwd, + }); + + return { thread, cwd: sessionCwd }; + }); + const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( event: Extract, ) { @@ -152,8 +193,11 @@ const make = Effect.gen(function* () { return; } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); + const { thread, cwd: checkpointCwd } = yield* resolveUsableWorkspaceCwdForThread({ + threadId: event.threadId, + turnId, + context: "checkpoint capture", + }); if (!thread) { return; } @@ -166,17 +210,6 @@ const make = Effect.gen(function* () { if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId)) { return; } - - const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); - const checkpointCwd = - Option.match(sessionRuntime, { - onNone: () => undefined, - onSome: (runtime) => runtime.cwd, - }) ?? - resolveThreadWorkspaceCwd({ - thread, - projects: readModel.projects, - }); if (!checkpointCwd) { yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", { threadId: thread.id, @@ -294,24 +327,14 @@ const make = Effect.gen(function* () { return; } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.id === event.threadId, - ); + const { thread, cwd: checkpointCwd } = yield* resolveUsableWorkspaceCwdForThread({ + threadId: event.threadId, + turnId, + context: "checkpoint pre-turn capture", + }); if (!thread) { return; } - - const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ - thread, - projects: readModel.projects, - }); - const checkpointCwd = - checkpointCwdFromThreadOrProject ?? - Option.match(yield* resolveSessionRuntimeForThread(thread.id), { - onNone: () => undefined, - onSome: (runtime) => runtime.cwd, - }); if (!checkpointCwd) { yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { threadId: thread.id, @@ -356,22 +379,13 @@ const make = Effect.gen(function* () { } const threadId = event.payload.threadId; - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); + const { thread, cwd: checkpointCwd } = yield* resolveUsableWorkspaceCwdForThread({ + threadId, + context: "checkpoint pre-turn capture", + }); if (!thread) { return; } - - const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ - thread, - projects: readModel.projects, - }); - const checkpointCwd = - checkpointCwdFromThreadOrProject ?? - Option.match(yield* resolveSessionRuntimeForThread(threadId), { - onNone: () => undefined, - onSome: (runtime) => runtime.cwd, - }); if (!checkpointCwd) { yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4f352435..fc44bb98 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -80,10 +80,18 @@ describe("ProviderCommandReactor", () => { createdStateDirs.clear(); }); - async function createHarness(input?: { readonly stateDir?: string }) { + async function createHarness(input?: { + readonly stateDir?: string; + readonly projectWorkspaceRoot?: string; + readonly createProjectWorkspace?: boolean; + }) { const now = new Date().toISOString(); const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdStateDirs.add(stateDir); + const projectWorkspaceRoot = input?.projectWorkspaceRoot ?? path.join(stateDir, "workspace"); + if (input?.createProjectWorkspace !== false) { + fs.mkdirSync(projectWorkspaceRoot, { recursive: true }); + } const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -221,7 +229,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.makeUnsafe("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot: projectWorkspaceRoot, defaultModel: "gpt-5-codex", createdAt: now, }), @@ -252,6 +260,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, generateBranchName, + projectWorkspaceRoot, stateDir, }; } @@ -281,7 +290,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: harness.projectWorkspaceRoot, model: "gpt-5-codex", runtimeMode: "approval-required", }); @@ -292,6 +301,39 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("omits missing thread workspace cwd when starting a provider session", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-missing-")); + createdStateDirs.add(stateDir); + const missingWorkspaceRoot = path.join(stateDir, "missing-workspace"); + const harness = await createHarness({ + stateDir, + projectWorkspaceRoot: missingWorkspaceRoot, + createProjectWorkspace: false, + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-missing-workspace"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-missing-workspace"), + role: "user", + text: "hello with missing workspace", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("cwd"); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ea6bfef6..c6e8eee9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; + import { type ChatAttachment, CommandId, @@ -196,6 +198,32 @@ const make = Effect.gen(function* () { return readModel.threads.find((entry) => entry.id === threadId); }); + const resolveUsableThreadWorkspaceCwd = Effect.fnUntraced(function* (threadId: ThreadId) { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + if (!thread) { + return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); + } + + const resolvedCwd = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); + if (!resolvedCwd) { + return { thread, cwd: undefined as string | undefined }; + } + + if (existsSync(resolvedCwd)) { + return { thread, cwd: resolvedCwd }; + } + + yield* Effect.logWarning("provider command reactor ignoring missing thread workspace cwd", { + threadId, + cwd: resolvedCwd, + }); + return { thread, cwd: undefined as string | undefined }; + }); + const ensureSessionForThread = Effect.fnUntraced(function* ( threadId: ThreadId, createdAt: string, @@ -207,11 +235,7 @@ const make = Effect.gen(function* () { readonly providerOptions?: ProviderStartOptions; }, ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); - if (!thread) { - return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); - } + const { thread, cwd: effectiveCwd } = yield* resolveUsableThreadWorkspaceCwd(threadId); const desiredRuntimeMode = thread.runtimeMode; const currentProvider = @@ -220,10 +244,6 @@ const make = Effect.gen(function* () { : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; - const effectiveCwd = resolveThreadWorkspaceCwd({ - thread, - projects: readModel.projects, - }); const resolveActiveSession = (threadId: ThreadId) => providerService.listSessions().pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 23b14e3e..10caf52a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -985,6 +985,192 @@ describe("ProviderRuntimeIngestion", () => { expect(completionEvents).toHaveLength(1); }); + it("does not create an empty assistant message before later buffered content arrives", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-empty-placeholder-guard"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-empty-placeholder-guard"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-empty-placeholder-guard", + ); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-empty-placeholder-guard"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-empty-placeholder-guard"), + itemId: asItemId("item-empty-placeholder-guard"), + payload: { + itemType: "assistant_message", + status: "completed", + detail: "", + }, + }); + + await Effect.runPromise(Effect.sleep("40 millis")); + let readModel = await Effect.runPromise(harness.engine.getReadModel()); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect( + thread?.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-empty-placeholder-guard", + ), + ).toBe(false); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-empty-placeholder-guard"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-empty-placeholder-guard"), + itemId: asItemId("item-empty-placeholder-guard"), + payload: { + streamKind: "assistant_text", + delta: "late answer", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-empty-placeholder-guard"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-empty-placeholder-guard"), + payload: { + state: "completed", + }, + }); + + thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "ready" && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-empty-placeholder-guard" && + message.text === "late answer" && + !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-empty-placeholder-guard", + ); + expect(message?.text).toBe("late answer"); + expect(message?.streaming).toBe(false); + }); + + it("ignores late assistant item completion after turn completion already finalized the message", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-late-item-complete"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late-item-complete"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-late-item-complete", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-late-item-complete"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late-item-complete"), + itemId: asItemId("item-late-item-complete"), + payload: { + streamKind: "assistant_text", + delta: "final answer", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-late-item-complete"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late-item-complete"), + payload: { + state: "completed", + }, + }); + + await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "ready" && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-late-item-complete" && + message.text === "final answer" && + !message.streaming, + ), + ); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-late-item-complete"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late-item-complete"), + itemId: asItemId("item-late-item-complete"), + payload: { + itemType: "assistant_message", + status: "completed", + detail: "final answer", + }, + }); + + await Effect.runPromise(Effect.sleep("40 millis")); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const matchingMessages = + thread?.messages.filter( + (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-late-item-complete", + ) ?? []; + expect(matchingMessages).toHaveLength(1); + expect(matchingMessages[0]?.text).toBe("final answer"); + + const events = await Effect.runPromise( + Stream.runCollect(harness.engine.readEvents(0)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ), + ); + const completionEvents = events.filter((event) => { + if (event.type !== "thread.message-sent") { + return false; + } + return ( + event.payload.messageId === "assistant:item-late-item-complete" && + event.payload.streaming === false + ); + }); + expect(completionEvents).toHaveLength(1); + }); + it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index bc0804b1..b107c8cd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -648,8 +648,18 @@ const make = Effect.gen(function* () { commandTag: string; finalDeltaCommandTag: string; fallbackText?: string; + existingMessage?: { + readonly id: MessageId; + readonly text: string; + readonly streaming: boolean; + }; }) => Effect.gen(function* () { + if (input.existingMessage && !input.existingMessage.streaming) { + yield* clearAssistantMessageState(input.messageId); + return; + } + const bufferedText = yield* takeBufferedAssistantText(input.messageId); const sawDelta = yield* takeAssistantMessageSawDelta(input.messageId); const text = @@ -659,6 +669,11 @@ const make = Effect.gen(function* () { ? input.fallbackText! : ""; + if (text.length === 0 && !input.existingMessage) { + yield* clearAssistantMessageState(input.messageId); + return; + } + if (text.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", @@ -798,6 +813,9 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const existingAssistantMessageById = new Map( + thread.messages.map((message) => [message.id, message] as const), + ); const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); @@ -969,6 +987,9 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete", finalDeltaCommandTag: "assistant-delta-finalize", + ...(existingAssistantMessageById.has(assistantMessageId) + ? { existingMessage: existingAssistantMessageById.get(assistantMessageId)! } + : {}), ...(assistantCompletion.fallbackText !== undefined ? { fallbackText: assistantCompletion.fallbackText } : {}), @@ -1006,6 +1027,9 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", + ...(existingAssistantMessageById.has(assistantMessageId) + ? { existingMessage: existingAssistantMessageById.get(assistantMessageId)! } + : {}), }), { concurrency: 1 }, ).pipe(Effect.asVoid); 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 00000000..3b263786 --- /dev/null +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; + +import { ThreadId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, vi } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; +import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); + +class FakeCopilotSession { + constructor(readonly sessionId: string) {} + + destroy = vi.fn(async () => undefined); + on = vi.fn((_handler: (event: never) => void) => () => undefined); + send = vi.fn(async () => "message-1"); + abort = vi.fn(async () => undefined); + getMessages = vi.fn(async () => []); +} + +class FakeCopilotClient { + connected = false; + readonly callLog: string[] = []; + readonly session = new FakeCopilotSession("session-1"); + + start = vi.fn(async () => { + this.callLog.push("start"); + this.connected = true; + }); + + listModels = vi.fn(async () => { + this.callLog.push("listModels"); + if (!this.connected) { + throw new Error("Client not connected"); + } + return [ + { + id: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + supports: { + vision: false, + reasoningEffort: false, + }, + limits: { + max_context_window_tokens: 200_000, + }, + }, + }, + ]; + }); + + createSession = vi.fn(async () => { + this.callLog.push("createSession"); + return this.session; + }); + + resumeSession = vi.fn(async () => { + this.callLog.push("resumeSession"); + return this.session; + }); + + stop = vi.fn(async () => []); +} + +const fakeClient = new FakeCopilotClient(); +const layer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => fakeClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); + +layer("CopilotAdapterLive startup", (it) => { + it.effect("starts the SDK client before model validation on new sessions", () => + Effect.gen(function* () { + fakeClient.connected = false; + fakeClient.callLog.length = 0; + fakeClient.start.mockClear(); + fakeClient.listModels.mockClear(); + fakeClient.createSession.mockClear(); + + const adapter = yield* CopilotAdapter; + const session = yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-copilot-start"), + model: "gpt-5.4", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "copilot"); + assert.equal(session.threadId, "thread-copilot-start"); + assert.deepStrictEqual(fakeClient.callLog, ["start", "listModels", "createSession"]); + assert.equal(fakeClient.start.mock.calls.length, 1); + assert.equal(fakeClient.listModels.mock.calls.length, 1); + assert.equal(fakeClient.createSession.mock.calls.length, 1); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 194b6a61..4e05a8c3 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -17,8 +17,8 @@ import { } from "@t3tools/contracts"; import { CopilotClient, + type CopilotClientOptions, type ModelInfo, - type CopilotSession, type PermissionRequest, type PermissionRequestResult, type SessionEvent, @@ -47,6 +47,7 @@ const TURN_START_TIMEOUT_MS = 1_500; export interface CopilotAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; + readonly clientFactory?: (options: CopilotClientOptions) => CopilotClientHandle; } interface PendingApprovalRequest { @@ -76,8 +77,8 @@ interface PendingUserInputRequest { } interface ActiveCopilotSession { - readonly client: CopilotClient; - session: CopilotSession; + readonly client: CopilotClientHandle; + session: CopilotSessionHandle; readonly threadId: ThreadId; readonly createdAt: string; readonly runtimeMode: ProviderSession["runtimeMode"]; @@ -94,6 +95,26 @@ interface ActiveCopilotSession { unsubscribe: () => void; } +interface CopilotSessionHandle { + readonly sessionId: string; + destroy(): Promise; + on(handler: (event: SessionEvent) => void): () => void; + send(options: { prompt: string; attachments?: unknown; mode?: string }): Promise; + abort(): Promise; + getMessages(): Promise; +} + +interface CopilotClientHandle { + start(): Promise; + listModels(): Promise; + createSession(config: Parameters[0]): Promise; + resumeSession( + sessionId: string, + config: Parameters[1], + ): Promise; + stop(): Promise; +} + function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; @@ -842,7 +863,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }; const validateSessionConfiguration = (input: { - readonly client: CopilotClient; + readonly client: CopilotClientHandle; readonly threadId: ThreadId; readonly model: string | undefined; readonly reasoningEffort: CodexReasoningEffort | undefined; @@ -852,6 +873,17 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => return; } + yield* Effect.tryPromise({ + try: () => input.client.start(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot client."), + cause, + }), + }); + const supportedModels = mapSupportedModelsById( yield* Effect.tryPromise({ try: () => input.client.listModels(), @@ -952,8 +984,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const createSessionRecord = (input: { readonly threadId: ThreadId; - readonly client: CopilotClient; - readonly session: CopilotSession; + readonly client: CopilotClientHandle; + readonly session: CopilotSessionHandle; readonly runtimeMode: ProviderSession["runtimeMode"]; readonly pendingApprovalResolvers: Map; readonly pendingUserInputResolvers: Map; @@ -1064,11 +1096,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => trimToUndefined(input.providerOptions?.copilot?.cliPath) ?? resolveBundledCopilotCliPath(); const configDir = trimToUndefined(input.providerOptions?.copilot?.configDir); const resumeSessionId = extractResumeSessionId(input.resumeCursor); - const client = new CopilotClient({ + const clientOptions: CopilotClientOptions = { ...(cliPath ? { cliPath } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), logLevel: "error", - }); + }; + const client = options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions); const pendingApprovalResolvers = new Map(); const pendingUserInputResolvers = new Map(); const reasoningEffort = getCopilotReasoningEffort(input.modelOptions); From 2551ac9e3a2565a77356bc53cec1cb3117b0ab84 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 11:04:50 +0100 Subject: [PATCH 06/14] fix: stabilize Copilot turn event mapping Keep a stable orchestration turn id across Copilot tool, approval, and user-input events while preserving provider turn refs and surfacing better tool titles from session events. --- .../provider/Layers/CopilotAdapter.test.ts | 144 +++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 144 ++++++++++-------- 2 files changed, 221 insertions(+), 67 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 3b263786..0acbd3f6 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import type { CopilotClient } from "@github/copilot-sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Fiber, Layer, Stream } from "effect"; import { ServerConfig } from "../../config.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; @@ -11,20 +12,36 @@ import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +type FakeSessionConfig = { + readonly onPermissionRequest?: (...args: ReadonlyArray) => Promise; +}; + class FakeCopilotSession { + handler: ((event: never) => void) | null = null; + constructor(readonly sessionId: string) {} destroy = vi.fn(async () => undefined); - on = vi.fn((_handler: (event: never) => void) => () => undefined); + on = vi.fn((handler: (event: never) => void) => { + this.handler = handler; + return () => { + this.handler = null; + }; + }); send = vi.fn(async () => "message-1"); abort = vi.fn(async () => undefined); getMessages = vi.fn(async () => []); + + emit(event: unknown) { + this.handler?.(event as never); + } } class FakeCopilotClient { connected = false; readonly callLog: string[] = []; readonly session = new FakeCopilotSession("session-1"); + lastCreateSessionConfig: FakeSessionConfig | undefined; start = vi.fn(async () => { this.callLog.push("start"); @@ -53,8 +70,9 @@ class FakeCopilotClient { ]; }); - createSession = vi.fn(async () => { + createSession = vi.fn(async (config: FakeSessionConfig) => { this.callLog.push("createSession"); + this.lastCreateSessionConfig = config; return this.session; }); @@ -69,7 +87,7 @@ class FakeCopilotClient { const fakeClient = new FakeCopilotClient(); const layer = it.layer( makeCopilotAdapterLive({ - clientFactory: () => fakeClient, + clientFactory: () => fakeClient as unknown as CopilotClient, }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), @@ -101,4 +119,120 @@ layer("CopilotAdapterLive startup", (it) => { assert.equal(fakeClient.createSession.mock.calls.length, 1); }), ); + + it.effect("keeps a stable orchestration turn id for Copilot tool and approval events", () => + Effect.gen(function* () { + fakeClient.connected = false; + fakeClient.callLog.length = 0; + fakeClient.lastCreateSessionConfig = undefined; + fakeClient.session.handler = null; + + const adapter = yield* CopilotAdapter; + yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-copilot-turn-map"), + model: "gpt-5.4", + runtimeMode: "full-access", + }); + + yield* Stream.runCollect(Stream.take(adapter.streamEvents, 8)).pipe(Effect.asVoid); + + const runtimeEventsFiber = yield* Stream.runCollect( + Stream.take(adapter.streamEvents, 8), + ).pipe(Effect.forkChild); + + const turn = yield* adapter.sendTurn({ + threadId: asThreadId("thread-copilot-turn-map"), + input: "update bb.py", + }); + + fakeClient.session.emit({ + id: "evt-tool-start", + type: "tool.execution_start", + timestamp: "2026-03-07T09:49:27.832Z", + data: { + toolCallId: "tool-1", + toolName: "bash", + arguments: "python3 bb.py", + }, + }); + + const onPermissionRequest = (fakeClient.lastCreateSessionConfig as + | FakeSessionConfig + | undefined)?.onPermissionRequest; + if (onPermissionRequest) { + void onPermissionRequest({ + kind: "read", + path: "/Users/zortos/junk/bb.py", + } as never); + } + + fakeClient.session.emit({ + id: "evt-turn-start", + type: "assistant.turn_start", + timestamp: "2026-03-07T09:49:56.130Z", + data: { + turnId: "provider-turn-1", + }, + }); + + fakeClient.session.emit({ + id: "evt-tool-complete", + type: "tool.execution_complete", + timestamp: "2026-03-07T09:49:56.131Z", + data: { + toolCallId: "tool-1", + toolName: "bash", + success: true, + result: { + content: "ok", + contents: [{ type: "terminal", text: "ok" }], + }, + }, + }); + + fakeClient.session.emit({ + id: "evt-assistant-message", + type: "assistant.message", + timestamp: "2026-03-07T09:49:57.807Z", + data: { + messageId: "msg-1", + content: "Updated bb.py", + }, + }); + + fakeClient.session.emit({ + id: "evt-turn-end", + type: "assistant.turn_end", + timestamp: "2026-03-07T09:49:57.808Z", + data: { + turnId: "provider-turn-1", + }, + }); + + const events = Array.from( + yield* Fiber.join(runtimeEventsFiber).pipe( + Effect.map((chunk): ReadonlyArray => Array.from(chunk)), + ), + ); + const toolStarted = events.find((event) => event.type === "item.started"); + const approvalOpened = events.find((event) => event.type === "request.opened"); + const turnStarted = events.find((event) => event.type === "turn.started"); + const toolCompleted = events.find( + (event) => event.type === "item.completed" && event.itemId === "tool-1", + ); + + assert.equal(turn.turnId.startsWith("copilot-turn-"), true); + assert.equal(toolStarted?.turnId, turn.turnId); + assert.equal(approvalOpened?.turnId, turn.turnId); + assert.ok(turnStarted); + assert.equal(turnStarted?.turnId, turn.turnId); + assert.equal(turnStarted?.providerRefs?.providerTurnId, "provider-turn-1"); + assert.equal(toolCompleted?.turnId, turn.turnId); + assert.equal( + toolCompleted?.type === "item.completed" ? toolCompleted.payload.title : undefined, + "bash", + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 4e05a8c3..8b80f46b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -43,7 +43,6 @@ import type { const PROVIDER = "copilot" as const; const USER_INPUT_QUESTION_ID = "answer"; -const TURN_START_TIMEOUT_MS = 1_500; export interface CopilotAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; @@ -89,7 +88,9 @@ interface ActiveCopilotSession { updatedAt: string; lastError: string | undefined; currentTurnId: TurnId | undefined; - pendingTurnStarts: Array<(turnId: TurnId) => void>; + currentProviderTurnId: TurnId | undefined; + pendingTurnIds: Array; + toolTitlesByCallId: Map; pendingApprovalResolvers: Map; pendingUserInputResolvers: Map; unsubscribe: () => void; @@ -261,12 +262,13 @@ function withRefs(input: { readonly eventId: EventId; readonly createdAt: string; readonly turnId: TurnId | undefined; + readonly providerTurnId?: TurnId | undefined; readonly itemId: string | undefined; readonly requestId: string | undefined; readonly rawMethod: string | undefined; readonly rawPayload: unknown; }): Omit { - const providerTurnId = input.turnId; + const providerTurnId = input.providerTurnId ?? input.turnId; const providerItemId = toProviderItemId(input.itemId); const providerRequestId = trimToUndefined(input.requestId); return { @@ -274,7 +276,7 @@ function withRefs(input: { provider: PROVIDER, threadId: input.threadId, createdAt: input.createdAt, - ...(providerTurnId ? { turnId: providerTurnId } : {}), + ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.itemId ? { itemId: toRuntimeItemId(input.itemId) } : {}), ...(input.requestId ? { requestId: toRuntimeRequestId(input.requestId) } : {}), ...(providerTurnId || providerItemId || providerRequestId @@ -389,8 +391,16 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => event: SessionEvent, ): ReadonlyArray => { const currentTurnId = record.currentTurnId; + const currentProviderTurnId = record.currentProviderTurnId; + const resolveOrchestrationTurnId = (providerTurnId: TurnId | undefined): TurnId | undefined => { + if (providerTurnId && currentProviderTurnId && providerTurnId === currentProviderTurnId) { + return currentTurnId ?? providerTurnId; + } + return currentTurnId ?? providerTurnId; + }; const base = (input?: { readonly turnId?: TurnId | undefined; + readonly providerTurnId?: TurnId | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; }) => @@ -398,7 +408,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => threadId: record.threadId, eventId: EventId.makeUnsafe(event.id), createdAt: event.timestamp, - turnId: input?.turnId ?? currentTurnId, + turnId: resolveOrchestrationTurnId(input?.providerTurnId ?? input?.turnId), + providerTurnId: input?.providerTurnId ?? input?.turnId, itemId: input?.itemId, requestId: input?.requestId, rawMethod: event.type, @@ -575,12 +586,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => case "assistant.turn_start": return [ { - ...base({ turnId: toTurnId(event.data.turnId) }), + ...base({ providerTurnId: toTurnId(event.data.turnId) }), type: "turn.started", payload: record.model ? { model: record.model } : {}, }, { - ...base({ turnId: toTurnId(event.data.turnId) }), + ...base({ providerTurnId: toTurnId(event.data.turnId) }), type: "session.state.changed", payload: { state: "running", @@ -641,7 +652,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => case "assistant.turn_end": return [ { - ...base({ turnId: toTurnId(event.data.turnId) }), + ...base({ providerTurnId: toTurnId(event.data.turnId) }), type: "turn.completed", payload: { state: "completed", @@ -724,7 +735,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ? "command_execution" : "dynamic_tool_call", status: event.data.success ? "completed" : "failed", - title: "Tool call", + title: record.toolTitlesByCallId.get(event.data.toolCallId) ?? "Tool call", ...(trimToUndefined(event.data.result?.content) ? { detail: event.data.result?.content } : {}), data: event.data, }, @@ -801,6 +812,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const createInteractionHandlers = ( threadId: ThreadId, + getCurrentTurnId: () => TurnId | undefined, pendingApprovalResolvers: Map, pendingUserInputResolvers: Map, ) => { @@ -821,10 +833,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ? { detail: requestDetailFromPermissionRequest(request) } : {}), args: request, - }, - { requestId }, - ), - ]); + }, + { requestId, turnId: getCurrentTurnId() }, + ), + ]); }); const onUserInputRequest = (request: CopilotUserInputRequest) => @@ -851,7 +863,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, ], }, - { requestId }, + { requestId, turnId: getCurrentTurnId() }, ), ]); }); @@ -953,6 +965,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const handlers = createInteractionHandlers( record.threadId, + () => record.currentTurnId, record.pendingApprovalResolvers, record.pendingUserInputResolvers, ); @@ -1006,7 +1019,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => updatedAt: new Date().toISOString(), lastError: undefined, currentTurnId: undefined, - pendingTurnStarts: [], + currentProviderTurnId: undefined, + pendingTurnIds: [], + toolTitlesByCallId: new Map(), pendingApprovalResolvers: input.pendingApprovalResolvers, pendingUserInputResolvers: input.pendingUserInputResolvers, unsubscribe: () => undefined, @@ -1015,13 +1030,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const handleSessionEvent = (record: ActiveCopilotSession, event: SessionEvent) => { record.updatedAt = event.timestamp; if (event.type === "assistant.turn_start") { - const turnId = TurnId.makeUnsafe(event.data.turnId); - record.currentTurnId = turnId; - const pendingStart = record.pendingTurnStarts.shift(); - pendingStart?.(turnId); - } - if (event.type === "assistant.turn_end" || event.type === "abort" || event.type === "session.idle") { - record.currentTurnId = undefined; + const providerTurnId = TurnId.makeUnsafe(event.data.turnId); + record.currentProviderTurnId = providerTurnId; + record.currentTurnId = record.pendingTurnIds.shift() ?? record.currentTurnId ?? providerTurnId; } if (event.type === "session.error") { record.lastError = event.data.message; @@ -1029,12 +1040,22 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => if (event.type === "session.model_change") { record.model = event.data.newModel; } + if (event.type === "tool.execution_start" && trimToUndefined(event.data.toolName)) { + record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); + } void writeNativeEvent(record.threadId, event); const runtimeEvents = mapSessionEvent(record, event); if (runtimeEvents.length > 0) { void emitRuntimeEvents(runtimeEvents); } + if (event.type === "tool.execution_complete") { + record.toolTitlesByCallId.delete(event.data.toolCallId); + } + if (event.type === "assistant.turn_end" || event.type === "abort" || event.type === "session.idle") { + record.currentTurnId = undefined; + record.currentProviderTurnId = undefined; + } }; const getSessionRecord = (threadId: ThreadId) => { @@ -1105,8 +1126,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const pendingApprovalResolvers = new Map(); const pendingUserInputResolvers = new Map(); const reasoningEffort = getCopilotReasoningEffort(input.modelOptions); + let sessionRecord: ActiveCopilotSession | undefined; const handlers = createInteractionHandlers( input.threadId, + () => sessionRecord?.currentTurnId, pendingApprovalResolvers, pendingUserInputResolvers, ); @@ -1164,6 +1187,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => handleSessionEvent(record, event); }); record.unsubscribe = unsubscribe; + sessionRecord = record; sessions.set(input.threadId, record); yield* Queue.offerAll(runtimeEventQueue, [ @@ -1249,21 +1273,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }); } - const fallbackTurnId = TurnId.makeUnsafe(`copilot-turn-${randomUUID()}`); - const turnIdPromise = new Promise((resolve) => { - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - resolve(fallbackTurnId); - }, TURN_START_TIMEOUT_MS); - record.pendingTurnStarts.push((turnId) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(turnId); - }); - }); + const turnId = TurnId.makeUnsafe(`copilot-turn-${randomUUID()}`); + record.pendingTurnIds.push(turnId); + record.currentTurnId = turnId; + record.currentProviderTurnId = undefined; yield* Effect.tryPromise({ try: () => @@ -1279,10 +1292,17 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => detail: toMessage(cause, "Failed to send GitHub Copilot turn."), cause, }), - }); + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + record.pendingTurnIds = record.pendingTurnIds.filter((candidate) => candidate !== turnId); + if (record.currentTurnId === turnId) { + record.currentTurnId = undefined; + } + }), + ), + ); - const turnId = yield* Effect.promise(() => turnIdPromise); - record.currentTurnId = turnId; record.updatedAt = new Date().toISOString(); return { @@ -1326,18 +1346,18 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => pending.resolve(approvalDecisionToPermissionResult(decision)); yield* Queue.offer( runtimeEventQueue, - makeSyntheticEvent( - threadId, - "request.resolved", - { - requestType: pending.requestType, - decision, - resolution: approvalDecisionToPermissionResult(decision), - }, - { requestId }, - ), - ); - }); + makeSyntheticEvent( + threadId, + "request.resolved", + { + requestType: pending.requestType, + decision, + resolution: approvalDecisionToPermissionResult(decision), + }, + { requestId, turnId: record.currentTurnId }, + ), + ); + }); const respondToUserInput: CopilotAdapterShape["respondToUserInput"] = ( threadId, @@ -1358,16 +1378,16 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => pending.resolve(resolveUserInputAnswer(pending, answers)); yield* Queue.offer( runtimeEventQueue, - makeSyntheticEvent( - threadId, - "user-input.resolved", - { - answers, - }, - { requestId }, - ), - ); - }); + makeSyntheticEvent( + threadId, + "user-input.resolved", + { + answers, + }, + { requestId, turnId: record.currentTurnId }, + ), + ); + }); const stopSession: CopilotAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { From b3ed5027ba0230dd43cfdb73245af8b56a1bb3cd Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 12:47:05 +0100 Subject: [PATCH 07/14] fix: preserve visible Copilot work across pending steps Keep approval and user-input resolution tied to their originating turn and let the chat work log keep showing the most relevant visible turn while a newer turn is still waiting for output. --- .../src/provider/Layers/CopilotAdapter.ts | 14 ++-- apps/web/src/components/ChatView.tsx | 28 +++++--- apps/web/src/session-logic.test.ts | 66 +++++++++++++++++ apps/web/src/session-logic.ts | 70 ++++++++++++++++++- 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 8b80f46b..6d1589c1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -56,6 +56,7 @@ interface PendingApprovalRequest { | "file_read_approval" | "dynamic_tool_call" | "unknown"; + readonly turnId: TurnId | undefined; readonly resolve: (result: PermissionRequestResult) => void; } @@ -72,6 +73,7 @@ interface CopilotUserInputResponse { interface PendingUserInputRequest { readonly request: CopilotUserInputRequest; + readonly turnId: TurnId | undefined; readonly resolve: (result: CopilotUserInputResponse) => void; } @@ -819,8 +821,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const onPermissionRequest = (request: PermissionRequest) => new Promise((resolve) => { const requestId = `copilot-approval-${randomUUID()}`; + const turnId = getCurrentTurnId(); pendingApprovalResolvers.set(requestId, { requestType: requestTypeFromPermissionRequest(request), + turnId, resolve, }); void emitRuntimeEvents([ @@ -834,7 +838,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => : {}), args: request, }, - { requestId, turnId: getCurrentTurnId() }, + { requestId, turnId }, ), ]); }); @@ -842,8 +846,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const onUserInputRequest = (request: CopilotUserInputRequest) => new Promise((resolve) => { const requestId = `copilot-user-input-${randomUUID()}`; + const turnId = getCurrentTurnId(); pendingUserInputResolvers.set(requestId, { request, + turnId, resolve, }); void emitRuntimeEvents([ @@ -863,7 +869,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, ], }, - { requestId, turnId: getCurrentTurnId() }, + { requestId, turnId }, ), ]); }); @@ -1354,7 +1360,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => decision, resolution: approvalDecisionToPermissionResult(decision), }, - { requestId, turnId: record.currentTurnId }, + { requestId, turnId: pending.turnId }, ), ); }); @@ -1384,7 +1390,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => { answers, }, - { requestId, turnId: record.currentTurnId }, + { requestId, turnId: pending.turnId }, ), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b6cb3914..bd541dad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,6 +70,7 @@ import { derivePhase, deriveTimelineEntries, deriveActivePlanState, + deriveVisibleWorkTurnId, findLatestProposedPlan, type PendingApproval, type PendingUserInput, @@ -928,14 +929,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - const latestTurnHasToolActivity = useMemo( - () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), - [activeLatestTurn?.turnId, threadActivities], - ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -944,6 +937,25 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const visibleWorkTurnId = useMemo( + () => + deriveVisibleWorkTurnId({ + activities: threadActivities, + latestTurnId: activeLatestTurn?.turnId ?? undefined, + session: activeThread?.session ?? null, + pendingApprovals, + pendingUserInputs, + }), + [activeLatestTurn?.turnId, activeThread?.session, pendingApprovals, pendingUserInputs, threadActivities], + ); + const workLogEntries = useMemo( + () => deriveWorkLogEntries(threadActivities, visibleWorkTurnId), + [threadActivities, visibleWorkTurnId], + ); + const latestTurnHasToolActivity = useMemo( + () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), + [activeLatestTurn?.turnId, threadActivities], + ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de3c2d62..d67c3e74 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -7,6 +7,7 @@ import { derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, + deriveVisibleWorkTurnId, deriveWorkLogEntries, findLatestProposedPlan, hasToolActivityForTurn, @@ -74,6 +75,7 @@ describe("derivePendingApprovals", () => { requestId: "req-1", requestKind: "command", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, detail: "bun run lint", }, ]); @@ -100,6 +102,7 @@ describe("derivePendingApprovals", () => { requestId: "req-request-type", requestKind: "command", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, detail: "pwd", }, ]); @@ -203,6 +206,7 @@ describe("derivePendingUserInputs", () => { { requestId: "req-user-input-1", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, questions: [ { id: "sandbox_mode", @@ -221,6 +225,68 @@ describe("derivePendingUserInputs", () => { }); }); +describe("deriveVisibleWorkTurnId", () => { + it("keeps showing the pending approval turn when the latest turn has not logged work yet", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + turnId: "turn-2", + payload: { + requestId: "req-approval", + requestKind: "command", + detail: "bun run lint", + }, + }), + ]; + + expect( + deriveVisibleWorkTurnId({ + activities, + latestTurnId: TurnId.makeUnsafe("turn-3"), + session: { orchestrationStatus: "running" }, + }), + ).toBe("turn-2"); + }); + + it("falls back to the latest visible work turn while a newer turn is still waiting on output", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "approval.resolved", + summary: "Approval resolved", + tone: "approval", + turnId: "turn-2", + payload: { + requestId: "req-approval", + requestKind: "command", + decision: "acceptForSession", + }, + }), + makeActivity({ + id: "tool-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "bash complete", + tone: "tool", + turnId: "turn-2", + }), + ]; + + expect( + deriveVisibleWorkTurnId({ + activities, + latestTurnId: TurnId.makeUnsafe("turn-3"), + session: { orchestrationStatus: "running" }, + }), + ).toBe("turn-2"); + }); +}); + describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 173245ac..717e9078 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,12 +35,14 @@ export interface PendingApproval { requestId: ApprovalRequestId; requestKind: "command" | "file-read" | "file-change"; createdAt: string; + turnId: TurnId | null; detail?: string; } export interface PendingUserInput { requestId: ApprovalRequestId; createdAt: string; + turnId: TurnId | null; questions: ReadonlyArray; } @@ -171,6 +173,7 @@ export function derivePendingApprovals( requestId, requestKind, createdAt: activity.createdAt, + turnId: activity.turnId, ...(detail ? { detail } : {}), }); continue; @@ -269,6 +272,7 @@ export function derivePendingUserInputs( openByRequestId.set(requestId, { requestId, createdAt: activity.createdAt, + turnId: activity.turnId, questions, }); continue; @@ -284,6 +288,68 @@ export function derivePendingUserInputs( ); } +function isVisibleWorkActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind === "tool.started") { + return false; + } + if (activity.kind === "task.started" || activity.kind === "task.completed") { + return false; + } + if (activity.summary === "Checkpoint captured") { + return false; + } + return true; +} + +export function deriveVisibleWorkTurnId(input: { + readonly activities: ReadonlyArray; + readonly latestTurnId: TurnId | undefined; + readonly session: Pick | null; + readonly pendingApprovals?: ReadonlyArray; + readonly pendingUserInputs?: ReadonlyArray; +}): TurnId | undefined { + const { + activities, + latestTurnId, + session, + pendingApprovals = derivePendingApprovals(activities), + pendingUserInputs = derivePendingUserInputs(activities), + } = input; + + const latestTurnHasVisibleWork = + latestTurnId !== undefined && + activities.some((activity) => activity.turnId === latestTurnId && isVisibleWorkActivity(activity)); + if (latestTurnHasVisibleWork) { + return latestTurnId; + } + + const newestPendingApprovalTurnId = [...pendingApprovals] + .toReversed() + .find((approval) => approval.turnId !== null)?.turnId; + if (newestPendingApprovalTurnId) { + return newestPendingApprovalTurnId; + } + + const newestPendingUserInputTurnId = [...pendingUserInputs] + .toReversed() + .find((userInput) => userInput.turnId !== null)?.turnId; + if (newestPendingUserInputTurnId) { + return newestPendingUserInputTurnId; + } + + if (session?.orchestrationStatus === "running") { + const latestVisibleWorkTurnId = [...activities] + .toSorted(compareActivitiesByOrder) + .toReversed() + .find((activity) => activity.turnId !== null && isVisibleWorkActivity(activity))?.turnId; + if (latestVisibleWorkTurnId) { + return latestVisibleWorkTurnId; + } + } + + return latestTurnId; +} + export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, @@ -394,9 +460,7 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); return ordered .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) - .filter((activity) => activity.kind !== "tool.started") - .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") - .filter((activity) => activity.summary !== "Checkpoint captured") + .filter(isVisibleWorkActivity) .map((activity) => { const payload = activity.payload && typeof activity.payload === "object" From 158e1f402900872b3f611f78ed31d3ad58026908 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 13:11:33 +0100 Subject: [PATCH 08/14] fix: simplify chat work log filtering Filter visible work-log entries by activities that happened after the latest user message instead of tracking a separate visible turn heuristic, which keeps follow-up Copilot work visible across provider turns with simpler session logic. --- apps/web/src/components/ChatView.tsx | 19 ++---- apps/web/src/session-logic.test.ts | 93 +++++++++------------------- apps/web/src/session-logic.ts | 51 +-------------- 3 files changed, 38 insertions(+), 125 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 478547d0..4b581e3c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -71,7 +71,6 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - deriveVisibleWorkTurnId, findLatestProposedPlan, type PendingApproval, type PendingUserInput, @@ -953,6 +952,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); + const serverMessages = activeThread?.messages; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -967,20 +967,14 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); - const visibleWorkTurnId = useMemo( + const latestUserMessageCreatedAt = useMemo( () => - deriveVisibleWorkTurnId({ - activities: threadActivities, - latestTurnId: activeLatestTurn?.turnId ?? undefined, - session: activeThread?.session ?? null, - pendingApprovals, - pendingUserInputs, - }), - [activeLatestTurn?.turnId, activeThread?.session, pendingApprovals, pendingUserInputs, threadActivities], + [...(serverMessages ?? [])].toReversed().find((message) => message.role === "user")?.createdAt, + [serverMessages], ); const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, visibleWorkTurnId), - [threadActivities, visibleWorkTurnId], + () => deriveWorkLogEntries(threadActivities, undefined, latestUserMessageCreatedAt), + [latestUserMessageCreatedAt, threadActivities], ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), @@ -1123,7 +1117,6 @@ export default function ChatView({ threadId }: ChatViewProps) { delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); }, []); - const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 411c5e3c..20841154 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -8,7 +8,6 @@ import { derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, - deriveVisibleWorkTurnId, deriveWorkLogEntries, findLatestProposedPlan, hasToolActivityForTurn, @@ -226,68 +225,6 @@ describe("derivePendingUserInputs", () => { }); }); -describe("deriveVisibleWorkTurnId", () => { - it("keeps showing the pending approval turn when the latest turn has not logged work yet", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - turnId: "turn-2", - payload: { - requestId: "req-approval", - requestKind: "command", - detail: "bun run lint", - }, - }), - ]; - - expect( - deriveVisibleWorkTurnId({ - activities, - latestTurnId: TurnId.makeUnsafe("turn-3"), - session: { orchestrationStatus: "running" }, - }), - ).toBe("turn-2"); - }); - - it("falls back to the latest visible work turn while a newer turn is still waiting on output", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-resolved", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "approval.resolved", - summary: "Approval resolved", - tone: "approval", - turnId: "turn-2", - payload: { - requestId: "req-approval", - requestKind: "command", - decision: "acceptForSession", - }, - }), - makeActivity({ - id: "tool-complete", - createdAt: "2026-02-23T00:00:03.000Z", - kind: "tool.completed", - summary: "bash complete", - tone: "tool", - turnId: "turn-2", - }), - ]; - - expect( - deriveVisibleWorkTurnId({ - activities, - latestTurnId: TurnId.makeUnsafe("turn-3"), - session: { orchestrationStatus: "running" }, - }), - ).toBe("turn-2"); - }); -}); - describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ @@ -498,6 +435,36 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]); }); + it("keeps work from multiple provider turns after the latest user message", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "older-turn", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Old work", + kind: "tool.completed", + turnId: "turn-1", + }), + makeActivity({ + id: "turn-2-tool", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "apply_patch complete", + kind: "tool.completed", + turnId: "turn-2", + }), + makeActivity({ + id: "turn-3-approval", + createdAt: "2026-02-23T00:00:04.000Z", + summary: "Approval resolved", + kind: "approval.resolved", + tone: "approval", + turnId: "turn-3", + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined, "2026-02-23T00:00:02.000Z"); + expect(entries.map((entry) => entry.id)).toEqual(["turn-2-tool", "turn-3-approval"]); + }); + it("extracts command text for command tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 709adfdc..33ebd8fd 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -317,55 +317,6 @@ function isVisibleWorkActivity(activity: OrchestrationThreadActivity): boolean { return true; } -export function deriveVisibleWorkTurnId(input: { - readonly activities: ReadonlyArray; - readonly latestTurnId: TurnId | undefined; - readonly session: Pick | null; - readonly pendingApprovals?: ReadonlyArray; - readonly pendingUserInputs?: ReadonlyArray; -}): TurnId | undefined { - const { - activities, - latestTurnId, - session, - pendingApprovals = derivePendingApprovals(activities), - pendingUserInputs = derivePendingUserInputs(activities), - } = input; - - const latestTurnHasVisibleWork = - latestTurnId !== undefined && - activities.some((activity) => activity.turnId === latestTurnId && isVisibleWorkActivity(activity)); - if (latestTurnHasVisibleWork) { - return latestTurnId; - } - - const newestPendingApprovalTurnId = [...pendingApprovals] - .toReversed() - .find((approval) => approval.turnId !== null)?.turnId; - if (newestPendingApprovalTurnId) { - return newestPendingApprovalTurnId; - } - - const newestPendingUserInputTurnId = [...pendingUserInputs] - .toReversed() - .find((userInput) => userInput.turnId !== null)?.turnId; - if (newestPendingUserInputTurnId) { - return newestPendingUserInputTurnId; - } - - if (session?.orchestrationStatus === "running") { - const latestVisibleWorkTurnId = [...activities] - .toSorted(compareActivitiesByOrder) - .toReversed() - .find((activity) => activity.turnId !== null && isVisibleWorkActivity(activity))?.turnId; - if (latestVisibleWorkTurnId) { - return latestVisibleWorkTurnId; - } - } - - return latestTurnId; -} - export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, @@ -472,10 +423,12 @@ export function findLatestProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, + sinceCreatedAt?: string, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); return ordered .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) + .filter((activity) => (sinceCreatedAt ? activity.createdAt >= sinceCreatedAt : true)) .filter(isVisibleWorkActivity) .map((activity) => { const payload = From 106c1aba1cbf2343658d1b7131e0bf4ef757a6f9 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 13:23:32 +0100 Subject: [PATCH 09/14] fix: streamline Copilot approvals and work summaries Auto-approve Copilot permission requests in full-access mode and align the chat completion summary plus composer effort persistence with the simplified post-user-message work-log model. --- .../provider/Layers/CopilotAdapter.test.ts | 34 ++++++++++++++++++- .../src/provider/Layers/CopilotAdapter.ts | 6 ++++ apps/web/src/components/ChatView.tsx | 19 +++++------ apps/web/src/composerDraftStore.ts | 8 +---- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 0acbd3f6..2060fe86 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -132,7 +132,7 @@ layer("CopilotAdapterLive startup", (it) => { provider: "copilot", threadId: asThreadId("thread-copilot-turn-map"), model: "gpt-5.4", - runtimeMode: "full-access", + runtimeMode: "approval-required", }); yield* Stream.runCollect(Stream.take(adapter.streamEvents, 8)).pipe(Effect.asVoid); @@ -235,4 +235,36 @@ layer("CopilotAdapterLive startup", (it) => { ); }), ); + + it.effect("auto-approves Copilot permission requests in full-access mode", () => + Effect.gen(function* () { + fakeClient.connected = false; + fakeClient.callLog.length = 0; + fakeClient.lastCreateSessionConfig = undefined; + fakeClient.session.handler = null; + + const adapter = yield* CopilotAdapter; + yield* adapter.startSession({ + provider: "copilot", + threadId: asThreadId("thread-copilot-full-access"), + model: "gpt-5.4", + runtimeMode: "full-access", + }); + + yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe(Effect.asVoid); + + const onPermissionRequest = (fakeClient.lastCreateSessionConfig as + | FakeSessionConfig + | undefined)?.onPermissionRequest; + assert.ok(onPermissionRequest); + const result = yield* Effect.promise(() => + onPermissionRequest({ + kind: "read", + path: "/Users/zortos/junk/aaa.py", + } as never) as Promise, + ); + + assert.deepStrictEqual(result, { kind: "approved" }); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6d1589c1..76a39790 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -815,10 +815,14 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const createInteractionHandlers = ( threadId: ThreadId, getCurrentTurnId: () => TurnId | undefined, + getRuntimeMode: () => ProviderSession["runtimeMode"], pendingApprovalResolvers: Map, pendingUserInputResolvers: Map, ) => { const onPermissionRequest = (request: PermissionRequest) => + getRuntimeMode() === "full-access" + ? Promise.resolve({ kind: "approved" }) + : new Promise((resolve) => { const requestId = `copilot-approval-${randomUUID()}`; const turnId = getCurrentTurnId(); @@ -972,6 +976,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const handlers = createInteractionHandlers( record.threadId, () => record.currentTurnId, + () => record.runtimeMode, record.pendingApprovalResolvers, record.pendingUserInputResolvers, ); @@ -1136,6 +1141,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const handlers = createInteractionHandlers( input.threadId, () => sessionRecord?.currentTurnId, + () => sessionRecord?.runtimeMode ?? input.runtimeMode, pendingApprovalResolvers, pendingUserInputResolvers, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4b581e3c..b485f937 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,7 +77,6 @@ import { type ProviderPickerKind, PROVIDER_OPTIONS, deriveWorkLogEntries, - hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, formatTimestamp, @@ -976,10 +975,6 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveWorkLogEntries(threadActivities, undefined, latestUserMessageCreatedAt), [latestUserMessageCreatedAt, threadActivities], ); - const latestTurnHasToolActivity = useMemo( - () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), - [activeLatestTurn?.turnId, threadActivities], - ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => @@ -1220,17 +1215,21 @@ export default function ChatView({ threadId }: ChatViewProps) { const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; - if (!latestTurnHasToolActivity) return null; + if (!activeLatestTurn?.completedAt) return null; + if (workLogEntries.length === 0) return null; + + const summaryStartAt = + latestUserMessageCreatedAt ?? workLogEntries[0]?.createdAt ?? activeLatestTurn.startedAt; + if (!summaryStartAt) return null; - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); + const elapsed = formatElapsed(summaryStartAt, activeLatestTurn.completedAt); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, - latestTurnHasToolActivity, latestTurnSettled, + latestUserMessageCreatedAt, + workLogEntries, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index bcf3836c..ad2d4abb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,5 +1,4 @@ import { - DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, @@ -914,12 +913,7 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const nextEffort = - effort && - REASONING_EFFORT_VALUES.has(effort) && - effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex - ? effort - : null; + const nextEffort = effort && REASONING_EFFORT_VALUES.has(effort) ? effort : null; set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && nextEffort === null) { From 5fd321419cfa8a584521e6c6ce3ff153656856e3 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 13:50:54 +0100 Subject: [PATCH 10/14] feat: add Copilot-powered git text generation Route git commit and PR generation through the selected provider so Git quick actions can use Copilot models and provider-specific options instead of always falling back to Codex. --- .../src/git/Layers/CodexTextGeneration.ts | 33 +- .../src/git/Layers/CopilotTextGeneration.ts | 295 ++++++++++++++++++ apps/server/src/git/Layers/GitManager.test.ts | 220 +++++++++++-- apps/server/src/git/Layers/GitManager.ts | 90 +++++- .../src/git/Services/CopilotTextGeneration.ts | 24 ++ .../server/src/git/Services/TextGeneration.ts | 16 +- apps/server/src/serverLayers.ts | 3 + apps/web/src/components/ChatView.tsx | 25 +- apps/web/src/components/GitActionsControl.tsx | 31 +- apps/web/src/lib/gitReactQuery.ts | 16 +- packages/contracts/src/git.ts | 6 + 11 files changed, 719 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/git/Layers/CopilotTextGeneration.ts create mode 100644 apps/server/src/git/Services/CopilotTextGeneration.ts diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93..ecbaa40e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -185,6 +185,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { cwd, prompt, outputSchemaJson, + binaryPath = "codex", + homePath, + model = CODEX_MODEL, + reasoningEffort = CODEX_REASONING_EFFORT, imagePaths = [], cleanupPaths = [], }: { @@ -192,6 +196,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { cwd: string; prompt: string; outputSchemaJson: S; + binaryPath?: string; + homePath?: string; + model?: string; + reasoningEffort?: string; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; }): Effect.Effect => @@ -205,16 +213,16 @@ const makeCodexTextGeneration = Effect.gen(function* () { const runCodexCommand = Effect.gen(function* () { const command = ChildProcess.make( - "codex", + binaryPath, [ "exec", "--ephemeral", "-s", "read-only", "--model", - CODEX_MODEL, + model, "--config", - `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, + `model_reasoning_effort="${reasoningEffort}"`, "--output-schema", schemaPath, "--output-last-message", @@ -224,6 +232,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { ], { cwd, + ...(homePath ? { env: { ...process.env, HOME: homePath } } : {}), shell: process.platform === "win32", stdin: { stream: Stream.make(new TextEncoder().encode(prompt)), @@ -314,6 +323,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { const wantsBranch = input.includeBranch === true; + const binaryPath = input.providerOptions?.codex?.binaryPath ?? "codex"; + const homePath = input.providerOptions?.codex?.homePath; + const model = input.model ?? CODEX_MODEL; + const reasoningEffort = + input.modelOptions?.codex?.reasoningEffort ?? CODEX_REASONING_EFFORT; const prompt = [ "You write concise git commit messages.", @@ -353,6 +367,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { cwd: input.cwd, prompt, outputSchemaJson, + binaryPath, + model, + reasoningEffort, + ...(homePath ? { homePath } : {}), }).pipe( Effect.map( (generated) => @@ -368,6 +386,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { + const binaryPath = input.providerOptions?.codex?.binaryPath ?? "codex"; + const homePath = input.providerOptions?.codex?.homePath; + const model = input.model ?? CODEX_MODEL; + const reasoningEffort = + input.modelOptions?.codex?.reasoningEffort ?? CODEX_REASONING_EFFORT; const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", @@ -394,6 +417,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generatePrContent", cwd: input.cwd, prompt, + binaryPath, + model, + reasoningEffort, + ...(homePath ? { homePath } : {}), outputSchemaJson: Schema.Struct({ title: Schema.String, body: Schema.String, diff --git a/apps/server/src/git/Layers/CopilotTextGeneration.ts b/apps/server/src/git/Layers/CopilotTextGeneration.ts new file mode 100644 index 00000000..51f8f168 --- /dev/null +++ b/apps/server/src/git/Layers/CopilotTextGeneration.ts @@ -0,0 +1,295 @@ +import { + CopilotClient, + type CopilotClientOptions, + type CopilotSession, + type PermissionRequestResult, +} from "@github/copilot-sdk"; +import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; +import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { Effect, Layer, Schema, SchemaIssue } from "effect"; + +import { TextGenerationError } from "../Errors.ts"; +import { + CopilotTextGeneration, + type CopilotTextGenerationShape, +} from "../Services/CopilotTextGeneration.ts"; +import type { + CommitMessageGenerationInput, + CommitMessageGenerationResult, + PrContentGenerationInput, + PrContentGenerationResult, +} from "../Services/TextGeneration.ts"; +import { resolveBundledCopilotCliPath } from "../../provider/Layers/copilotCliPath.ts"; + +const COPILOT_TIMEOUT_MS = 180_000; +const DENY_PERMISSION_RESULT: PermissionRequestResult = { + kind: "denied-interactively-by-user", +}; + +const CommitMessageResponseSchema = Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.optional(Schema.String), +}); + +const PrContentResponseSchema = Schema.Struct({ + title: Schema.String, + body: Schema.String, +}); + +interface CopilotClientHandle { + createSession(config: Parameters[0]): Promise; + stop(): Promise>; +} + +export interface CopilotTextGenerationLiveOptions { + readonly clientFactory?: (options: CopilotClientOptions) => CopilotClientHandle; +} + +function normalizeCopilotError( + operation: "generateCommitMessage" | "generatePrContent", + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if (lower.includes("enoent") || lower.includes("spawn")) { + return new TextGenerationError({ + operation, + detail: "GitHub Copilot CLI is required but was not found.", + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} + +function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + return singleLine.length > 0 ? singleLine : "Update project changes"; +} + +function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("```")) { + const fenced = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); + return fenced.trim(); + } + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start !== -1 && end !== -1 && end > start) { + return trimmed.slice(start, end + 1); + } + return trimmed; +} + +function decodeJsonResponse( + operation: "generateCommitMessage" | "generatePrContent", + raw: string, + schema: S, +): Effect.Effect { + return Effect.gen(function* () { + const jsonText = extractJsonObject(raw); + const parsed = yield* Effect.try({ + try: () => JSON.parse(jsonText) as unknown, + catch: (cause) => + normalizeCopilotError(operation, cause, "GitHub Copilot returned invalid JSON"), + }); + + return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `GitHub Copilot returned an unexpected payload: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + }); +} + +export const makeCopilotTextGenerationLive = (options?: CopilotTextGenerationLiveOptions) => + Layer.effect( + CopilotTextGeneration, + Effect.sync(() => { + const runCopilotJson = ({ + operation, + input, + prompt, + schema, + }: { + operation: "generateCommitMessage" | "generatePrContent"; + input: CommitMessageGenerationInput | PrContentGenerationInput; + prompt: string; + schema: S; + }): Effect.Effect => + Effect.gen(function* () { + const cliPath = + input.providerOptions?.copilot?.cliPath ?? resolveBundledCopilotCliPath(); + const configDir = input.providerOptions?.copilot?.configDir; + const model = input.model ?? DEFAULT_MODEL_BY_PROVIDER.copilot; + const reasoningEffort = input.modelOptions?.copilot?.reasoningEffort; + const clientOptions: CopilotClientOptions = { + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }; + const client = options?.clientFactory?.(clientOptions) ?? new CopilotClient(clientOptions); + let session: CopilotSession | undefined; + const cleanup = Effect.promise(async () => { + if (session) { + await session.destroy().catch(() => undefined); + } + await client.stop().catch(() => []); + }).pipe(Effect.asVoid); + + return yield* Effect.gen(function* () { + const createdSession = yield* Effect.tryPromise({ + try: () => + client.createSession({ + model, + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(configDir ? { configDir } : {}), + onPermissionRequest: () => DENY_PERMISSION_RESULT, + systemMessage: { + mode: "append", + content: + "Do not use tools, do not request permissions, and answer using only valid JSON with no markdown fences or prose.", + }, + }), + catch: (cause) => + normalizeCopilotError( + operation, + cause, + "Failed to start a GitHub Copilot text-generation session", + ), + }); + session = createdSession; + + const response = yield* Effect.tryPromise({ + try: () => createdSession.sendAndWait({ prompt, mode: "immediate" }, COPILOT_TIMEOUT_MS), + catch: (cause) => + normalizeCopilotError( + operation, + cause, + "GitHub Copilot did not finish generating text", + ), + }); + + if (!response?.data.content) { + return yield* new TextGenerationError({ + operation, + detail: "GitHub Copilot did not return any text.", + }); + } + + return yield* decodeJsonResponse(operation, response.data.content, schema); + }).pipe(Effect.ensuring(cleanup)); + }); + + const generateCommitMessage: CopilotTextGenerationShape["generateCommitMessage"] = (input) => { + const prompt = [ + "You write concise git commit messages.", + input.includeBranch === true + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and have no trailing period", + "- body can be an empty string or short bullet points", + ...(input.includeBranch === true + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + return runCopilotJson({ + operation: "generateCommitMessage", + input, + prompt, + schema: CommitMessageResponseSchema, + }).pipe( + Effect.map( + (generated) => + ({ + subject: generated.subject, + body: generated.body.trim(), + ...(generated.branch ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), + }) satisfies CommitMessageGenerationResult, + ), + ); + }; + + const generatePrContent: CopilotTextGenerationShape["generatePrContent"] = (input) => { + const prompt = [ + "You write GitHub pull request content.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + return runCopilotJson({ + operation: "generatePrContent", + input, + prompt, + schema: PrContentResponseSchema, + }).pipe( + Effect.map( + (generated) => + ({ + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }) satisfies PrContentGenerationResult, + ), + ); + }; + + return { + generateCommitMessage, + generatePrContent, + } satisfies CopilotTextGenerationShape; + }), + ); + +export const CopilotTextGenerationLive = makeCopilotTextGenerationLive(); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d8a3753b..e5c13334 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -1,19 +1,29 @@ import fs from "node:fs"; import path from "node:path"; +import { type GitRunStackedActionInput } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; +import { + type CopilotTextGenerationShape, + CopilotTextGeneration, +} from "../Services/CopilotTextGeneration.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; import { type GitHubCliShape, type GitHubPullRequestSummary, GitHubCli, } from "../Services/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + type CommitMessageGenerationInput, + type PrContentGenerationInput, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; @@ -27,30 +37,25 @@ interface FakeGhScenario { } interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - }) => Effect.Effect< + generateCommitMessage: (input: CommitMessageGenerationInput) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, TextGenerationError >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; + generatePrContent: (input: PrContentGenerationInput) => Effect.Effect< + { title: string; body: string }, + TextGenerationError + >; generateBranchName: (input: { cwd: string; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; } +interface FakeCopilotGitTextGeneration { + generateCommitMessage: FakeGitTextGeneration["generateCommitMessage"]; + generatePrContent: FakeGitTextGeneration["generatePrContent"]; +} + function makeTempDir( prefix: string, ): Effect.Effect { @@ -165,6 +170,50 @@ function createTextGeneration(overrides: Partial = {}): T }; } +function createCopilotTextGeneration( + overrides: Partial = {}, +): CopilotTextGenerationShape { + const implementation: FakeCopilotGitTextGeneration = { + generateCommitMessage: (input) => + Effect.succeed({ + subject: `Copilot: ${input.model ?? "default"}`, + body: "", + ...(input.includeBranch ? { branch: "feature/copilot-default" } : {}), + }), + generatePrContent: (input) => + Effect.succeed({ + title: `Copilot PR: ${input.model ?? input.headBranch}`, + body: "## Summary\n- Generated by Copilot\n\n## Testing\n- Not run", + }), + ...overrides, + }; + + return { + generateCommitMessage: (input) => + implementation.generateCommitMessage(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateCommitMessage", + detail: "fake copilot text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generatePrContent: (input) => + implementation.generatePrContent(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generatePrContent", + detail: "fake copilot text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + }; +} + function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { service: GitHubCliShape; ghCalls: string[]; @@ -286,12 +335,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { function runStackedAction( manager: GitManagerShape, - input: { - cwd: string; - action: "commit" | "commit_push" | "commit_push_pr"; - commitMessage?: string; - featureBranch?: boolean; - }, + input: GitRunStackedActionInput, ) { return manager.runStackedAction(input); } @@ -299,9 +343,11 @@ function runStackedAction( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + copilotTextGeneration?: Partial; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); + const copilotTextGeneration = createCopilotTextGeneration(input?.copilotTextGeneration); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), @@ -311,6 +357,7 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed(CopilotTextGeneration, copilotTextGeneration), gitCoreLayer, NodeServices.layer, ); @@ -495,6 +542,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses Copilot commit generation with the selected model when requested", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncopilot\n"); + let codexCommitCalls = 0; + let copilotCommitInput: + | Parameters[0] + | undefined; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + codexCommitCalls += 1; + return { + subject: `Codex should not run ${input.model ?? "unknown"}`, + body: "", + }; + }), + }, + copilotTextGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + copilotCommitInput = input; + return { + subject: "Copilot commit title", + body: "- generated with the selected Copilot model", + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + provider: "copilot", + model: "gpt-5.4", + modelOptions: { copilot: { reasoningEffort: "high" } }, + providerOptions: { copilot: { cliPath: "/tmp/copilot", configDir: "/tmp/copilot-config" } }, + }); + + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("Copilot commit title"); + expect(codexCommitCalls).toBe(0); + expect(copilotCommitInput?.provider).toBe("copilot"); + expect(copilotCommitInput?.model).toBe("gpt-5.4"); + expect(copilotCommitInput?.modelOptions).toEqual({ + copilot: { reasoningEffort: "high" }, + }); + expect(copilotCommitInput?.providerOptions).toEqual({ + copilot: { cliPath: "/tmp/copilot", configDir: "/tmp/copilot-config" }, + }); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -849,6 +952,79 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses Copilot PR generation with the selected model when requested", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature-copilot-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "copilot pr\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-copilot-pr"]); + yield* runGit(repoDir, ["config", "branch.feature-copilot-pr.gh-merge-base", "main"]); + let codexPrCalls = 0; + let copilotPrInput: + | Parameters[0] + | undefined; + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 99, + title: "Copilot PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/99", + baseRefName: "main", + headRefName: "feature-copilot-pr", + }, + ]), + ], + }, + textGeneration: { + generatePrContent: () => + Effect.sync(() => { + codexPrCalls += 1; + return { + title: "Codex should not run", + body: "## Summary\n- wrong\n\n## Testing\n- wrong", + }; + }), + }, + copilotTextGeneration: { + generatePrContent: (input) => + Effect.sync(() => { + copilotPrInput = input; + return { + title: "Copilot PR title", + body: "## Summary\n- generated by Copilot\n\n## Testing\n- Not run", + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + provider: "copilot", + model: "claude-sonnet-4.6", + }); + + expect(result.pr.status).toBe("created"); + expect(codexPrCalls).toBe(0); + expect(copilotPrInput?.provider).toBe("copilot"); + expect(copilotPrInput?.model).toBe("claude-sonnet-4.6"); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature-copilot-pr --title Copilot PR title"), + ), + ).toBe(true); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index c4a29e15..dbe5e103 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { GitManagerError } from "../Errors.ts"; +import { CopilotTextGeneration } from "../Services/CopilotTextGeneration.ts"; import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; @@ -179,6 +180,7 @@ export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const copilotTextGeneration = yield* CopilotTextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -279,6 +281,10 @@ export const makeGitManager = Effect.gen(function* () { cwd: string; branch: string | null; commitMessage?: string; + provider?: "codex" | "copilot"; + model?: string; + modelOptions?: Parameters[0]["modelOptions"]; + providerOptions?: Parameters[0]["providerOptions"]; /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; }) => @@ -300,12 +306,19 @@ export const makeGitManager = Effect.gen(function* () { }; } - const generated = yield* textGeneration + const generationService = + input.provider === "copilot" ? copilotTextGeneration : textGeneration; + + const generated = yield* generationService .generateCommitMessage({ cwd: input.cwd, branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.modelOptions ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), ...(input.includeBranch ? { includeBranch: true } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -323,6 +336,12 @@ export const makeGitManager = Effect.gen(function* () { branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, + textGenerationInput?: { + provider?: "codex" | "copilot"; + model?: string; + modelOptions?: Parameters[0]["modelOptions"]; + providerOptions?: Parameters[0]["providerOptions"]; + }, ) => Effect.gen(function* () { const suggestion = @@ -331,6 +350,14 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(textGenerationInput?.provider ? { provider: textGenerationInput.provider } : {}), + ...(textGenerationInput?.model ? { model: textGenerationInput.model } : {}), + ...(textGenerationInput?.modelOptions + ? { modelOptions: textGenerationInput.modelOptions } + : {}), + ...(textGenerationInput?.providerOptions + ? { providerOptions: textGenerationInput.providerOptions } + : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -344,7 +371,16 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null) => + const runPrStep = ( + cwd: string, + fallbackBranch: string | null, + textGenerationInput?: { + provider?: "codex" | "copilot"; + model?: string; + modelOptions?: Parameters[0]["modelOptions"]; + providerOptions?: Parameters[0]["providerOptions"]; + }, + ) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -376,13 +412,22 @@ export const makeGitManager = Effect.gen(function* () { const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); - const generated = yield* textGeneration.generatePrContent({ + const generationService = + textGenerationInput?.provider === "copilot" ? copilotTextGeneration : textGeneration; + + const generated = yield* generationService.generatePrContent({ cwd, baseBranch, headBranch: branch, commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(textGenerationInput?.provider ? { provider: textGenerationInput.provider } : {}), + ...(textGenerationInput?.model ? { model: textGenerationInput.model } : {}), + ...(textGenerationInput?.modelOptions ? { modelOptions: textGenerationInput.modelOptions } : {}), + ...(textGenerationInput?.providerOptions + ? { providerOptions: textGenerationInput.providerOptions } + : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -445,12 +490,30 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => + const runFeatureBranchStep = ( + cwd: string, + branch: string | null, + commitMessage?: string, + textGenerationInput?: { + provider?: "codex" | "copilot"; + model?: string; + modelOptions?: Parameters[0]["modelOptions"]; + providerOptions?: Parameters[0]["providerOptions"]; + }, + ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(textGenerationInput?.provider ? { provider: textGenerationInput.provider } : {}), + ...(textGenerationInput?.model ? { model: textGenerationInput.model } : {}), + ...(textGenerationInput?.modelOptions + ? { modelOptions: textGenerationInput.modelOptions } + : {}), + ...(textGenerationInput?.providerOptions + ? { providerOptions: textGenerationInput.providerOptions } + : {}), includeBranch: true, }); if (!suggestion) { @@ -499,6 +562,12 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, initialStatus.branch, input.commitMessage, + { + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.modelOptions ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + }, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -514,6 +583,12 @@ export const makeGitManager = Effect.gen(function* () { currentBranch, commitMessageForStep, preResolvedCommitSuggestion, + { + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.modelOptions ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + }, ); const push = wantsPush @@ -521,7 +596,12 @@ export const makeGitManager = Effect.gen(function* () { : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, currentBranch) + ? yield* runPrStep(input.cwd, currentBranch, { + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.modelOptions ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + }) : { status: "skipped_not_requested" as const }; return { diff --git a/apps/server/src/git/Services/CopilotTextGeneration.ts b/apps/server/src/git/Services/CopilotTextGeneration.ts new file mode 100644 index 00000000..2409761d --- /dev/null +++ b/apps/server/src/git/Services/CopilotTextGeneration.ts @@ -0,0 +1,24 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { TextGenerationError } from "../Errors.ts"; +import type { + CommitMessageGenerationInput, + CommitMessageGenerationResult, + PrContentGenerationInput, + PrContentGenerationResult, +} from "./TextGeneration.ts"; + +export interface CopilotTextGenerationShape { + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; +} + +export class CopilotTextGeneration extends ServiceMap.Service< + CopilotTextGeneration, + CopilotTextGenerationShape +>()("t3/git/Services/CopilotTextGeneration") {} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe..54ce486e 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,7 +8,13 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { + ChatAttachment, + ModelSlug, + ProviderKind, + ProviderModelOptions, + ProviderStartOptions, +} from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; @@ -17,6 +23,10 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + provider?: ProviderKind | undefined; + model?: ModelSlug | undefined; + modelOptions?: ProviderModelOptions | undefined; + providerOptions?: ProviderStartOptions | undefined; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; } @@ -35,6 +45,10 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + provider?: ProviderKind | undefined; + model?: ModelSlug | undefined; + modelOptions?: ProviderModelOptions | undefined; + providerOptions?: ProviderStartOptions | undefined; } export interface PrContentGenerationResult { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index eb6cb42e..9a29fb47 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -31,6 +31,7 @@ import { KeybindingsLive } from "./keybindings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { CopilotTextGenerationLive } from "./git/Layers/CopilotTextGeneration"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; @@ -75,6 +76,7 @@ export function makeServerProviderLayer(): Layer.Layer< export function makeServerRuntimeServicesLayer() { const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); const textGenerationLayer = CodexTextGenerationLive; + const copilotTextGenerationLayer = CopilotTextGenerationLive; const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -121,6 +123,7 @@ export function makeServerRuntimeServicesLayer() { const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(copilotTextGenerationLayer), Layer.provideMerge(textGenerationLayer), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b485f937..b43d13cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -17,6 +17,8 @@ import { type ServerProviderModel, type ServerProviderStatus, type ProviderKind, + type ProviderModelOptions, + type ProviderStartOptions, type ThreadId, type TurnId, OrchestrationThreadActivity, @@ -3497,6 +3499,10 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId={activeThread.id} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} + provider={selectedProvider} + model={selectedModel} + modelOptions={selectedModelOptionsForDispatch} + providerOptions={selectedProviderOptionsForDispatch} isGitRepo={isGitRepo} openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} activeProjectScripts={activeProject?.scripts} @@ -4081,6 +4087,10 @@ interface ChatHeaderProps { activeThreadId: ThreadId; activeThreadTitle: string; activeProjectName: string | undefined; + provider: ProviderKind; + model: ModelSlug; + modelOptions: ProviderModelOptions | undefined; + providerOptions: ProviderStartOptions | undefined; isGitRepo: boolean; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; @@ -4100,6 +4110,10 @@ const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, activeProjectName, + provider, + model, + modelOptions, + providerOptions, isGitRepo, openInCwd, activeProjectScripts, @@ -4153,7 +4167,16 @@ const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadId, + provider, + model, + modelOptions, + providerOptions, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -324,6 +343,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + provider, + model, + ...(modelOptions ? { modelOptions } : {}), + ...(providerOptions ? { providerOptions } : {}), }); try { @@ -415,6 +438,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [ isDefaultBranch, + model, + modelOptions, + provider, + providerOptions, runImmediateGitActionMutation, setPendingDefaultBranchAction, threadToastData, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index dbd7ad1c..7f53ee6d 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import type { GitStackedAction } from "@t3tools/contracts"; +import type { GitRunStackedActionInput } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -97,11 +97,11 @@ export function gitRunStackedActionMutationOptions(input: { action, commitMessage, featureBranch, - }: { - action: GitStackedAction; - commitMessage?: string; - featureBranch?: boolean; - }) => { + provider, + model, + modelOptions, + providerOptions, + }: Omit) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git action is unavailable."); return api.git.runStackedAction({ @@ -109,6 +109,10 @@ export function gitRunStackedActionMutationOptions(input: { action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(modelOptions ? { modelOptions } : {}), + ...(providerOptions ? { providerOptions } : {}), }); }, onSettled: async () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 80ede248..7613177d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderModelOptions } from "./model"; +import { ProviderKind, ProviderStartOptions } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -53,6 +55,10 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + provider: Schema.optional(ProviderKind), + model: Schema.optional(TrimmedNonEmptyStringSchema), + modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; From c25fd5510fcf65ab2624dfe249819f4fcbf98a6c Mon Sep 17 00:00:00 2001 From: Zortos Date: Sat, 7 Mar 2026 14:03:24 +0100 Subject: [PATCH 11/14] feat: surface Copilot quota and billing metadata Expose Copilot billing multipliers and quota snapshots from provider health so the model picker can show per-model cost hints and remaining usage directly in the chat UI. --- .../provider/Layers/ProviderHealth.test.ts | 74 ++++++++- .../src/provider/Layers/ProviderHealth.ts | 63 +++++++- apps/web/src/components/ChatView.tsx | 151 ++++++++++++++++-- packages/contracts/src/server.ts | 16 +- 4 files changed, 283 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 58eac64d..ba96d78d 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; +import type { ModelInfo } from "@github/copilot-sdk"; import { it } from "@effect/vitest"; import { Effect, Layer, Sink, Stream } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; +import { + checkCodexProviderStatus, + mapCopilotModel, + mapCopilotQuotaSnapshots, + parseAuthStatusFromOutput, +} from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -188,3 +194,69 @@ it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { assert.strictEqual(parsed.status, "warning"); assert.strictEqual(parsed.authStatus, "unknown"); }); + +it("mapCopilotModel carries billing multipliers", () => { + const model: ModelInfo = { + id: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + supports: { + vision: true, + reasoningEffort: true, + }, + limits: { + max_context_window_tokens: 200_000, + }, + }, + billing: { multiplier: 30 }, + supportedReasoningEfforts: ["medium", "high"], + defaultReasoningEffort: "high", + }; + const mapped = mapCopilotModel(model); + + assert.strictEqual(mapped.billingMultiplier, 30); + assert.deepStrictEqual(mapped.supportedReasoningEfforts, ["medium", "high"]); + assert.strictEqual(mapped.defaultReasoningEffort, "high"); +}); + +it("mapCopilotQuotaSnapshots prioritizes premium interactions and clamps remaining requests", () => { + const mapped = mapCopilotQuotaSnapshots({ + chat: { + entitlementRequests: 500, + usedRequests: 125, + remainingPercentage: 75, + overage: 0, + overageAllowedWithExhaustedQuota: false, + }, + premium_interactions: { + entitlementRequests: 300, + usedRequests: 320, + remainingPercentage: 0, + overage: 20, + overageAllowedWithExhaustedQuota: true, + resetDate: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.deepStrictEqual(mapped, [ + { + key: "premium_interactions", + entitlementRequests: 300, + usedRequests: 320, + remainingRequests: 0, + remainingPercentage: 0, + overage: 20, + overageAllowedWithExhaustedQuota: true, + resetDate: "2026-01-02T00:00:00.000Z", + }, + { + key: "chat", + entitlementRequests: 500, + usedRequests: 125, + remainingRequests: 375, + remainingPercentage: 75, + overage: 0, + overageAllowedWithExhaustedQuota: false, + }, + ]); +}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index f60c088b..1f64f409 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -11,6 +11,7 @@ import type { ServerProviderAuthStatus, ServerProviderModel, + ServerProviderQuotaSnapshot, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; @@ -38,7 +39,9 @@ interface CopilotHealthProbeError { readonly cause: unknown; } -function mapCopilotModel(model: ModelInfo): ServerProviderModel { +const COPILOT_QUOTA_PRIORITY = ["premium_interactions", "chat", "completions"] as const; + +export function mapCopilotModel(model: ModelInfo): ServerProviderModel { return { id: model.id, name: model.name, @@ -47,9 +50,54 @@ function mapCopilotModel(model: ModelInfo): ServerProviderModel { ? { supportedReasoningEfforts: [...model.supportedReasoningEfforts] } : {}), ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), + ...(typeof model.billing?.multiplier === "number" + ? { billingMultiplier: model.billing.multiplier } + : {}), } satisfies ServerProviderModel; } +interface CopilotQuotaSnapshotInfo { + readonly entitlementRequests: number; + readonly usedRequests: number; + readonly remainingPercentage: number; + readonly overage: number; + readonly overageAllowedWithExhaustedQuota: boolean; + readonly resetDate?: string; +} + +function compareCopilotQuotaKeys(left: string, right: string): number { + const leftPriority = COPILOT_QUOTA_PRIORITY.indexOf(left as (typeof COPILOT_QUOTA_PRIORITY)[number]); + const rightPriority = COPILOT_QUOTA_PRIORITY.indexOf(right as (typeof COPILOT_QUOTA_PRIORITY)[number]); + const normalizedLeftPriority = leftPriority === -1 ? Number.POSITIVE_INFINITY : leftPriority; + const normalizedRightPriority = rightPriority === -1 ? Number.POSITIVE_INFINITY : rightPriority; + return normalizedLeftPriority - normalizedRightPriority || left.localeCompare(right); +} + +export function mapCopilotQuotaSnapshots( + quotaSnapshots: Record | undefined, +): ReadonlyArray { + if (!quotaSnapshots) return []; + + return Object.entries(quotaSnapshots) + .toSorted(([leftKey], [rightKey]) => compareCopilotQuotaKeys(leftKey, rightKey)) + .map(([key, snapshot]) => { + const entitlementRequests = Math.max(0, Math.trunc(snapshot.entitlementRequests)); + const usedRequests = Math.max(0, Math.trunc(snapshot.usedRequests)); + const mapped: ServerProviderQuotaSnapshot = { + key, + entitlementRequests, + usedRequests, + remainingRequests: Math.max(0, entitlementRequests - usedRequests), + remainingPercentage: snapshot.remainingPercentage, + overage: Math.max(0, Math.trunc(snapshot.overage)), + overageAllowedWithExhaustedQuota: snapshot.overageAllowedWithExhaustedQuota, + }; + return snapshot.resetDate + ? Object.assign({}, mapped, { resetDate: snapshot.resetDate }) + : mapped; + }); +} + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -327,9 +375,14 @@ export const checkCopilotProviderStatus: Effect.Effect = E client.getStatus(), client.getAuthStatus().catch(() => undefined), ]); - const models = - authStatus?.isAuthenticated === true ? await client.listModels().catch(() => undefined) : undefined; - return { status, authStatus, models }; + const [models, quota] = + authStatus?.isAuthenticated === true + ? await Promise.all([ + client.listModels().catch(() => undefined), + client.rpc.account.getQuota().catch(() => undefined), + ]) + : [undefined, undefined]; + return { status, authStatus, models, quota }; } finally { await client.stop().catch(() => []); } @@ -375,6 +428,7 @@ export const checkCopilotProviderStatus: Effect.Effect = E : "unknown"; const status: ServerProviderStatusState = authStatus === "unauthenticated" ? "error" : authStatus === "unknown" ? "warning" : "ready"; + const quotaSnapshots = mapCopilotQuotaSnapshots(probe.success.value.quota?.quotaSnapshots); return { provider: COPILOT_PROVIDER, @@ -385,6 +439,7 @@ export const checkCopilotProviderStatus: Effect.Effect = E ...(probe.success.value.models && probe.success.value.models.length > 0 ? { models: probe.success.value.models.map(mapCopilotModel) } : {}), + ...(quotaSnapshots.length > 0 ? { quotaSnapshots } : {}), ...(probe.success.value.authStatus?.statusMessage ? { message: probe.success.value.authStatus.statusMessage } : probe.success.value.status?.version diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b43d13cf..905d5523 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -15,6 +15,7 @@ import { type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ServerProviderModel, + type ServerProviderQuotaSnapshot, type ServerProviderStatus, type ProviderKind, type ProviderModelOptions, @@ -266,6 +267,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PROVIDER_MODELS: ServerProviderModel[] = []; +const COPILOT_QUOTA_PRIORITY = ["premium_interactions", "chat", "completions"] as const; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; @@ -813,8 +815,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; const selectedServiceTier = selectedProvider === "codex" ? resolveAppServiceTier(selectedServiceTierSetting) : null; - const copilotProviderModels = - providerStatuses.find((status) => status.provider === "copilot")?.models ?? EMPTY_PROVIDER_MODELS; + const copilotProviderStatus = + providerStatuses.find((status) => status.provider === "copilot") ?? null; + const copilotProviderModels = copilotProviderStatus?.models ?? EMPTY_PROVIDER_MODELS; + const copilotQuotaSummary = useMemo( + () => deriveCopilotQuotaSummary(copilotProviderStatus?.quotaSnapshots), + [copilotProviderStatus?.quotaSnapshots], + ); const builtInModelOptionsByProvider = useMemo>>( () => ({ codex: getModelOptions("codex"), @@ -3742,6 +3749,8 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} + copilotModels={copilotProviderModels} + copilotQuotaSummary={copilotQuotaSummary} serviceTierSetting={selectedServiceTierSetting} onProviderModelChange={onProviderModelSelect} /> @@ -5487,11 +5496,88 @@ function resolveModelForProviderPicker( return null; } +function formatCopilotBillingMultiplier(multiplier: number): string { + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(multiplier)}x`; +} + +function formatCopilotQuotaLabel(key: string): string { + return key + .split(/[_-]+/) + .filter(Boolean) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" "); +} + +function normalizeCopilotRemainingPercentage(value: number): number { + const normalized = value <= 1 ? value * 100 : value; + return Math.min(100, Math.max(0, normalized)); +} + +function getCopilotQuotaPriority(key: string): number { + const index = COPILOT_QUOTA_PRIORITY.findIndex((candidate) => candidate === key); + return index === -1 ? Number.POSITIVE_INFINITY : index; +} + +function pickCopilotQuotaSnapshot( + quotaSnapshots: ReadonlyArray | undefined, +): ServerProviderQuotaSnapshot | null { + if (!quotaSnapshots || quotaSnapshots.length === 0) return null; + + return quotaSnapshots.toSorted((left, right) => { + const priorityDiff = getCopilotQuotaPriority(left.key) - getCopilotQuotaPriority(right.key); + if (priorityDiff !== 0) return priorityDiff; + return left.key.localeCompare(right.key); + })[0] ?? null; +} + +function formatCopilotQuotaResetDate(value: string | undefined): string | null { + if (!value) return null; + const resetDate = new Date(value); + if (Number.isNaN(resetDate.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: resetDate.getFullYear() === new Date().getFullYear() ? undefined : "numeric", + }).format(resetDate); +} + +function deriveCopilotQuotaSummary( + quotaSnapshots: ReadonlyArray | undefined, +): { title: string; detail: string } | null { + const snapshot = pickCopilotQuotaSnapshot(quotaSnapshots); + if (!snapshot) return null; + + const detailParts: string[] = []; + if (snapshot.entitlementRequests > 0) { + detailParts.push(`${snapshot.remainingRequests}/${snapshot.entitlementRequests} left`); + } else { + detailParts.push( + `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format( + normalizeCopilotRemainingPercentage(snapshot.remainingPercentage), + )}% remaining`, + ); + } + if (snapshot.overage > 0) { + detailParts.push(`${snapshot.overage} overage`); + } + const resetDate = formatCopilotQuotaResetDate(snapshot.resetDate); + if (resetDate) { + detailParts.push(`resets ${resetDate}`); + } + + return { + title: formatCopilotQuotaLabel(snapshot.key), + detail: detailParts.join(" · "), + }; +} + const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; + copilotModels: ReadonlyArray; + copilotQuotaSummary: { title: string; detail: string } | null; serviceTierSetting: AppServiceTier; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -5500,6 +5586,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + const copilotModelById = useMemo( + () => new Map(props.copilotModels.map((model) => [model.id, model])), + [props.copilotModels], + ); + const selectedCopilotModel = + props.provider === "copilot" ? copilotModelById.get(props.model) ?? null : null; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; return ( @@ -5529,6 +5621,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : null} {selectedModelLabel} + {selectedCopilotModel?.billingMultiplier != null ? ( + + {formatCopilotBillingMultiplier(selectedCopilotModel.billingMultiplier)} + + ) : null}