diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index e1b679c2fe..81acefa25d 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -173,6 +173,7 @@ export class AmpServerManager extends EventEmitter<{ }> { private readonly sessions = new Map(); private readonly logger = createLogger("amp"); + binaryPath: string | undefined; // ── Session lifecycle ─────────────────────────────────────────── @@ -187,7 +188,7 @@ export class AmpServerManager extends EventEmitter<{ } } - const binaryPath = defaultBinaryPath(); + const binaryPath = this.binaryPath?.trim() || defaultBinaryPath(); const cwd = input.cwd ?? process.cwd(); const model = input.modelSelection?.model; const now = new Date().toISOString(); diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 9acb99fd86..f517946442 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -210,6 +210,7 @@ export class GeminiCliServerManager extends EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); + binaryPath: string | undefined; startSession(input: ProviderSessionStartInput): Promise { const threadId = input.threadId; @@ -217,7 +218,7 @@ export class GeminiCliServerManager extends EventEmitter<{ throw new Error(`Gemini CLI session already exists for thread ${threadId}`); } - const binaryPath = defaultBinaryPath(); + const binaryPath = this.binaryPath?.trim() || defaultBinaryPath(); const cwd = input.cwd ?? process.cwd(); const resumeSessionId = readGeminiResumeSessionId(input.resumeCursor); const now = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts new file mode 100644 index 0000000000..18a2a0164d --- /dev/null +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; + +import { + ApprovalRequestId, + EventId, + RuntimeItemId, + ThreadId, + TurnId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, +} from "@t3tools/contracts"; +import { it, vi } from "@effect/vitest"; +import { Effect, Layer, Stream } from "effect"; + +import { AmpServerManager } from "../../ampServerManager.ts"; +import { AmpAdapter } from "../Services/AmpAdapter.ts"; +import { makeAmpAdapterLive } from "./AmpAdapter.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asItemId = (value: string): RuntimeItemId => RuntimeItemId.makeUnsafe(value); + +class FakeAmpManager extends AmpServerManager { + public startSessionImpl = vi.fn(async (threadId: ThreadId): Promise => { + const now = new Date().toISOString(); + return { + provider: "amp", + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + resumeCursor: { sessionId: `session-${threadId}` }, + } as unknown as ProviderSession; + }); + + public sendTurnImpl = vi.fn( + async (threadId: ThreadId): Promise => ({ + threadId, + turnId: asTurnId(`turn-${threadId}`), + }), + ); + + public interruptTurnImpl = vi.fn(async (): Promise => undefined); + public respondToRequestImpl = vi.fn(async (): Promise => undefined); + public respondToUserInputImpl = vi.fn(async (): Promise => undefined); + public readThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); + public rollbackThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); + public stopAllImpl = vi.fn(() => undefined); + + override startSession(input: { threadId: ThreadId }): Promise { + return this.startSessionImpl(input.threadId); + } + + override sendTurn(input: { threadId: ThreadId }): Promise { + return this.sendTurnImpl(input.threadId); + } + + override interruptTurn(_threadId: ThreadId): Promise { + return this.interruptTurnImpl(); + } + + override respondToRequest( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _decision: ProviderApprovalDecision, + ): Promise { + return this.respondToRequestImpl(); + } + + override respondToUserInput( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ): Promise { + return this.respondToUserInputImpl(); + } + + override readThread(threadId: ThreadId) { + return this.readThreadImpl(threadId); + } + + override rollbackThread(threadId: ThreadId) { + return this.rollbackThreadImpl(threadId); + } + + override stopSession(_threadId: ThreadId): void {} + + override listSessions(): ProviderSession[] { + return []; + } + + override hasSession(_threadId: ThreadId): boolean { + return false; + } + + override stopAll(): void { + this.stopAllImpl(); + } +} + +const manager = new FakeAmpManager(); +const layer = it.layer( + makeAmpAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), +); + +layer("AmpAdapterLive", (it) => { + it.effect("delegates session startup to the manager", () => + Effect.gen(function* () { + manager.startSessionImpl.mockClear(); + const adapter = yield* AmpAdapter; + + const session = yield* adapter.startSession({ + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "amp"); + assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + }), + ); + + it.effect("rejects attachments until AMP attachment wiring exists", () => + Effect.gen(function* () { + const adapter = yield* AmpAdapter; + const result = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-attachments"), + input: "hello", + attachments: [{ id: "attachment-1" }] as never, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("forwards manager runtime events through the adapter stream", () => + Effect.gen(function* () { + const adapter = yield* AmpAdapter; + + const event = { + type: "content.delta", + eventId: asEventId("evt-amp-delta"), + provider: "amp", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("item-1"), + payload: { + streamKind: "assistant_text", + delta: "hello", + }, + } as unknown as ProviderRuntimeEvent; + + // Emit first — the event is buffered in the unbounded queue via the + // listener that was registered during layer construction. + manager.emit("event", event); + + // Now consume the head. Since the queue already has an item, this + // resolves immediately without a race condition. + const received = yield* Stream.runHead(adapter.streamEvents); + + assert.equal(received._tag, "Some"); + if (received._tag !== "Some") { + return; + } + assert.equal(received.value.type, "content.delta"); + if (received.value.type !== "content.delta") { + return; + } + assert.equal(received.value.payload.delta, "hello"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AmpAdapter.ts b/apps/server/src/provider/Layers/AmpAdapter.ts index 4156371f5e..5a703ccb8c 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.ts @@ -2,68 +2,27 @@ import { type ProviderRuntimeEvent } from "@t3tools/contracts"; import { Effect, Layer, Queue, Stream } from "effect"; import { AmpServerManager } from "../../ampServerManager.ts"; -import { - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { AmpAdapter, type AmpAdapterShape } from "../Services/AmpAdapter.ts"; +import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "amp" as const; +const { toRequestError } = makeErrorHelpers(PROVIDER); export interface AmpAdapterLiveOptions { readonly manager?: AmpServerManager; readonly makeManager?: () => AmpServerManager; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toSessionError(threadId: string, cause: unknown) { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown amp session") || normalized.includes("unknown session")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: string, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - export function makeAmpAdapterLive(options: AmpAdapterLiveOptions = {}) { return Layer.effect( AmpAdapter, Effect.gen(function* () { const manager = options.manager ?? options.makeManager?.() ?? new AmpServerManager(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; yield* Effect.acquireRelease( Effect.sync(() => { @@ -85,9 +44,31 @@ export function makeAmpAdapterLive(options: AmpAdapterLiveOptions = {}) { provider: PROVIDER, capabilities: getProviderCapabilities(PROVIDER), startSession: (input) => - Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), + Effect.gen(function* () { + const providerSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.amp), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + if (!providerSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "AMP provider is disabled in server settings.", + }); + } + manager.binaryPath = providerSettings.binaryPath.trim() || undefined; + return yield* Effect.tryPromise({ + try: () => manager.startSession(input), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); }), sendTurn: (input) => { if ((input.attachments?.length ?? 0) > 0) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d7474c67b9..b4d3a229b8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,10 +26,7 @@ import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "ef import { ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, - type ProviderAdapterError, } from "../Errors.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; @@ -41,7 +38,14 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { toMessage } from "../toMessage.ts"; +import { + asArray, + asNumber, + asObject, + asString, + makeErrorHelpers, + toMessage, +} from "./ProviderAdapterUtils.ts"; const PROVIDER = "codex" as const; @@ -55,59 +59,10 @@ export interface CodexAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -function toSessionError( - threadId: ThreadId, - cause: unknown, -): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("session is closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asArray(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} +const { toRequestError } = makeErrorHelpers(PROVIDER, { + sessionNotFoundHints: ["unknown session", "unknown provider session"], + sessionClosedHint: "session is closed", +}); function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | undefined { const usage = asObject(value); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 23774710ec..e940e18758 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -8,6 +8,7 @@ import { afterAll, it, vi } from "@effect/vitest"; import { Effect, Fiber, Layer, Stream } from "effect"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; @@ -124,6 +125,7 @@ const modeLayer = it.layer( clientFactory: () => modeClient, }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ), ); @@ -171,6 +173,7 @@ const planLayer = it.layer( clientFactory: () => planClient, }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index a475486d69..bd3044da81 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -27,6 +27,7 @@ import { Effect, Layer, Queue, Stream } from "effect"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -447,6 +448,7 @@ function createSessionRecord(input: { const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* ServerSettingsService; const nativeEventLogger = options?.nativeEventLogger; const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); @@ -1256,6 +1258,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => sessions.delete(record.threadId); }; + /** Resolved CLI path from server settings; updated on each startSession call. */ + let resolvedCliPath: string | undefined; + const startSession: CopilotAdapterShape["startSession"] = (input) => Effect.gen(function* () { if (input.provider !== undefined && input.provider !== PROVIDER) { @@ -1266,6 +1271,18 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }); } + const copilotSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.copilot), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); const existing = sessions.get(input.threadId); if (existing) { return { @@ -1281,8 +1298,16 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...(existing.lastError ? { lastError: existing.lastError } : {}), } satisfies ProviderSession; } - - const cliPath = resolveBundledCopilotCliPath(); + if (!copilotSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Copilot provider is disabled in server settings.", + }); + } + const settingsBinaryPath = copilotSettings.binaryPath.trim(); + const cliPath = settingsBinaryPath || resolveBundledCopilotCliPath(); + resolvedCliPath = cliPath; const configDir: string | undefined = undefined; const resumeSessionId = extractResumeSessionId(input.resumeCursor); const clientOptions: CopilotClientOptions = { @@ -1691,7 +1716,7 @@ export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { // ── Dynamic model discovery & usage (consumed by wsServer) ───────── -export async function fetchCopilotModels(): Promise { @@ -371,7 +372,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) total_tokens: 46, }); } - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("extracts assistant text from structured Cursor chunk envelopes", () => { @@ -419,7 +420,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) event.payload.delta === "hello", ); assert.equal(assistantDelta?.type, "content.delta"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("passes requested model to ACP process startup", () => { @@ -449,7 +450,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) }); assert.deepEqual(createProcessInput?.model, "composer-1.5"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("writes provider-native observability records when enabled", () => { @@ -495,7 +496,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true, ); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("resumes ACP session using resumeCursor.acpSessionId", () => { @@ -530,7 +531,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) assert.deepEqual(session.resumeCursor, { acpSessionId: "acp-session-resume", }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("accepts legacy resumeCursor.sessionId for ACP session resume", () => { @@ -561,7 +562,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) assert.deepEqual(session.resumeCursor, { acpSessionId: "acp-session-legacy", }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("bridges permission requests to request.opened/request.resolved", () => { @@ -616,7 +617,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) } assert.equal(resolved.value.payload.decision, "acceptForSession"); assert.equal(fake.lastPermissionSelection, "allow-always"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("auto-approves cursor permission requests when approval policy is never", () => { @@ -652,7 +653,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) assert.equal(resolved.value.payload.decision, "acceptForSession"); assert.equal(fake.lastPermissionSelection, "allow-always"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("rejects empty prompt input before starting a turn", () => { @@ -696,7 +697,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) fake.requests.some((request) => request.method === "session/prompt"), false, ); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("keeps tool_call item types consistent through tool_call_update", () => { @@ -741,7 +742,7 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) assert.equal(started.payload.itemType, "command_execution"); assert.equal(completed.payload.itemType, "command_execution"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); it.effect("completes the turn when Cursor prompt completion lacks a stop reason", () => { @@ -791,6 +792,6 @@ opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) const sessions = yield* adapter.listSessions(); assert.equal(sessions[0]?.status, "ready"); assert.equal(sessions[0]?.activeTurnId, undefined); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(ServerSettingsService.layerTest())))); }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index c74bb9b729..694dbbd11d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -35,6 +35,7 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { CursorAdapter, @@ -46,7 +47,7 @@ import { CursorAcpSessionUpdateNotification, } from "../Services/CursorAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { toMessage } from "../toMessage.ts"; +import { asObject, asString, makeErrorHelpers, toMessage } from "./ProviderAdapterUtils.ts"; const PROVIDER = "cursor" as const; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; @@ -258,51 +259,9 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(value); } -function toSessionError( - threadId: ThreadId, - cause: unknown, -): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown session") || normalized.includes("not found")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} +const { toRequestError } = makeErrorHelpers(PROVIDER, { + sessionNotFoundHints: ["unknown session", "not found"], +}); function appendChunkText(fragments: string[], value: unknown): void { if (typeof value === "string" && value.length > 0) { @@ -543,6 +502,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const sessions = new Map(); + const serverSettingsService = yield* ServerSettingsService; const runtimeEventQueue = yield* Queue.unbounded(); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -1195,9 +1155,28 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } + const cursorSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.cursor), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + if (!cursorSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Cursor provider is disabled in server settings.", + }); + } const startedAt = yield* nowIso; const cwd = input.cwd ?? process.cwd(); - const binaryPath = "agent"; + const binaryPath = cursorSettings.binaryPath.trim() || "agent"; const resumeState = readCursorResumeState(input.resumeCursor); const child = yield* Effect.try({ diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts new file mode 100644 index 0000000000..884c3d14eb --- /dev/null +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; + +import { + ApprovalRequestId, + EventId, + RuntimeItemId, + ThreadId, + TurnId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, +} from "@t3tools/contracts"; +import { it, vi } from "@effect/vitest"; +import { Effect, Layer, Stream } from "effect"; + +import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; +import { GeminiCliAdapter } from "../Services/GeminiCliAdapter.ts"; +import { makeGeminiCliAdapterLive } from "./GeminiCliAdapter.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asItemId = (value: string): RuntimeItemId => RuntimeItemId.makeUnsafe(value); + +class FakeGeminiCliManager extends GeminiCliServerManager { + public startSessionImpl = vi.fn(async (threadId: ThreadId): Promise => { + const now = new Date().toISOString(); + return { + provider: "geminiCli", + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + resumeCursor: { sessionId: `session-${threadId}` }, + } as unknown as ProviderSession; + }); + + public sendTurnImpl = vi.fn( + async (threadId: ThreadId): Promise => ({ + threadId, + turnId: asTurnId(`turn-${threadId}`), + }), + ); + + public interruptTurnImpl = vi.fn(async (): Promise => undefined); + public respondToRequestImpl = vi.fn(async (): Promise => undefined); + public respondToUserInputImpl = vi.fn(async (): Promise => undefined); + public readThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); + public rollbackThreadImpl = vi.fn(async (threadId: ThreadId) => ({ threadId, turns: [] })); + public stopAllImpl = vi.fn(() => undefined); + + override startSession(input: { threadId: ThreadId }): Promise { + return this.startSessionImpl(input.threadId); + } + + override sendTurn(input: { threadId: ThreadId }): Promise { + return this.sendTurnImpl(input.threadId); + } + + override interruptTurn(_threadId: ThreadId): Promise { + return this.interruptTurnImpl(); + } + + override respondToRequest( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _decision: ProviderApprovalDecision, + ): Promise { + return this.respondToRequestImpl(); + } + + override respondToUserInput( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ): Promise { + return this.respondToUserInputImpl(); + } + + override readThread(threadId: ThreadId) { + return this.readThreadImpl(threadId); + } + + override rollbackThread(threadId: ThreadId) { + return this.rollbackThreadImpl(threadId); + } + + override stopSession(_threadId: ThreadId): void {} + + override listSessions(): ProviderSession[] { + return []; + } + + override hasSession(_threadId: ThreadId): boolean { + return false; + } + + override stopAll(): void { + this.stopAllImpl(); + } +} + +const manager = new FakeGeminiCliManager(); +const layer = it.layer( + makeGeminiCliAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), +); + +layer("GeminiCliAdapterLive", (it) => { + it.effect("delegates session startup to the manager", () => + Effect.gen(function* () { + manager.startSessionImpl.mockClear(); + const adapter = yield* GeminiCliAdapter; + + const session = yield* adapter.startSession({ + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "geminiCli"); + assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + }), + ); + + it.effect("rejects attachments until Gemini CLI attachment wiring exists", () => + Effect.gen(function* () { + const adapter = yield* GeminiCliAdapter; + const result = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-attachments"), + input: "hello", + attachments: [{ id: "attachment-1" }] as never, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("forwards manager runtime events through the adapter stream", () => + Effect.gen(function* () { + const adapter = yield* GeminiCliAdapter; + + const event = { + type: "content.delta", + eventId: asEventId("evt-gemini-delta"), + provider: "geminiCli", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("item-1"), + payload: { + streamKind: "assistant_text", + delta: "hello", + }, + } as unknown as ProviderRuntimeEvent; + + // Emit first — the event is buffered in the unbounded queue via the + // listener that was registered during layer construction. + manager.emit("event", event); + + // Now consume the head. Since the queue already has an item, this + // resolves immediately without a race condition. + const received = yield* Stream.runHead(adapter.streamEvents); + + assert.equal(received._tag, "Some"); + if (received._tag !== "Some") { + return; + } + assert.equal(received.value.type, "content.delta"); + if (received.value.type !== "content.delta") { + return; + } + assert.equal(received.value.payload.delta, "hello"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.ts index 2e0e839b17..1c14bf9743 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.ts @@ -2,68 +2,29 @@ import { type ProviderRuntimeEvent } from "@t3tools/contracts"; import { Effect, Layer, Queue, Stream } from "effect"; import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; -import { - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { GeminiCliAdapter, type GeminiCliAdapterShape } from "../Services/GeminiCliAdapter.ts"; +import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "geminiCli" as const; +const { toRequestError } = makeErrorHelpers(PROVIDER, { + sessionNotFoundHints: ["unknown gemini cli session", "unknown session"], +}); export interface GeminiCliAdapterLiveOptions { readonly manager?: GeminiCliServerManager; readonly makeManager?: () => GeminiCliServerManager; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toSessionError(threadId: string, cause: unknown) { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown gemini cli session") || normalized.includes("unknown session")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: string, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - export function makeGeminiCliAdapterLive(options: GeminiCliAdapterLiveOptions = {}) { return Layer.effect( GeminiCliAdapter, Effect.gen(function* () { const manager = options.manager ?? options.makeManager?.() ?? new GeminiCliServerManager(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; yield* Effect.acquireRelease( Effect.sync(() => { @@ -85,9 +46,31 @@ export function makeGeminiCliAdapterLive(options: GeminiCliAdapterLiveOptions = provider: PROVIDER, capabilities: getProviderCapabilities(PROVIDER), startSession: (input) => - Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), + Effect.gen(function* () { + const providerSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.geminiCli), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + if (!providerSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Gemini CLI provider is disabled in server settings.", + }); + } + manager.binaryPath = providerSettings.binaryPath.trim() || undefined; + return yield* Effect.tryPromise({ + try: () => manager.startSession(input), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); }), sendTurn: (input) => { if ((input.attachments?.length ?? 0) > 0) { diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index ad9eaa08d7..c1ab0803d4 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -13,11 +13,12 @@ import { type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Stream } from "effect"; +import { Effect, Layer, Stream } from "effect"; import { KiloServerManager } from "../../kiloServerManager.ts"; import { KiloAdapter } from "../Services/KiloAdapter.ts"; import { makeKiloAdapterLive } from "./KiloAdapter.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); @@ -105,7 +106,9 @@ class FakeKiloManager extends KiloServerManager { } const manager = new FakeKiloManager(); -const layer = it.layer(makeKiloAdapterLive({ manager })); +const layer = it.layer( + makeKiloAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), +); layer("KiloAdapterLive", (it) => { it.effect("delegates session startup to the manager", () => diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 0befe7590f..8856467687 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -2,68 +2,28 @@ import { type ProviderRuntimeEvent } from "@t3tools/contracts"; import { Effect, Layer, Queue, Stream } from "effect"; import { KiloServerManager } from "../../kiloServerManager.ts"; -import { - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; +import type { KiloSessionStartInput } from "../../kilo/types.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { KiloAdapter, type KiloAdapterShape } from "../Services/KiloAdapter.ts"; +import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "kilo" as const; +const { toRequestError } = makeErrorHelpers(PROVIDER); export interface KiloAdapterLiveOptions { readonly manager?: KiloServerManager; readonly makeManager?: () => KiloServerManager; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toSessionError(threadId: string, cause: unknown) { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown kilo session") || normalized.includes("unknown session")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: string, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - export function makeKiloAdapterLive(options: KiloAdapterLiveOptions = {}) { return Layer.effect( KiloAdapter, Effect.gen(function* () { const manager = options.manager ?? options.makeManager?.() ?? new KiloServerManager(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; yield* Effect.acquireRelease( Effect.sync(() => { @@ -85,9 +45,32 @@ export function makeKiloAdapterLive(options: KiloAdapterLiveOptions = {}) { provider: PROVIDER, capabilities: getProviderCapabilities(PROVIDER), startSession: (input) => - Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), + Effect.gen(function* () { + const providerSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.kilo), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + if (!providerSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Kilo provider is disabled in server settings.", + }); + } + const binaryPath = providerSettings.binaryPath.trim() || "kilo"; + return yield* Effect.tryPromise({ + try: () => + manager.startSession({ ...input, kilo: { binaryPath } } as KiloSessionStartInput), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); }), sendTurn: (input) => { if ((input.attachments?.length ?? 0) > 0) { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c198ad1699..5aceb69e80 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -13,11 +13,12 @@ import { type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Stream } from "effect"; +import { Effect, Layer, Stream } from "effect"; import { OpenCodeServerManager } from "../../opencodeServerManager.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; import { makeOpenCodeAdapterLive } from "./OpenCodeAdapter.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); @@ -105,7 +106,9 @@ class FakeOpenCodeManager extends OpenCodeServerManager { } const manager = new FakeOpenCodeManager(); -const layer = it.layer(makeOpenCodeAdapterLive({ manager })); +const layer = it.layer( + makeOpenCodeAdapterLive({ manager }).pipe(Layer.provideMerge(ServerSettingsService.layerTest())), +); layer("OpenCodeAdapterLive", (it) => { it.effect("delegates session startup to the manager", () => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 146b17fbce..08e9111ba1 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2,68 +2,28 @@ import { type ProviderRuntimeEvent } from "@t3tools/contracts"; import { Effect, Layer, Queue, Stream } from "effect"; import { OpenCodeServerManager } from "../../opencodeServerManager.ts"; -import { - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; +import type { OpenCodeSessionStartInput } from "../../opencode/types.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { makeErrorHelpers } from "./ProviderAdapterUtils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "opencode" as const; +const { toRequestError } = makeErrorHelpers(PROVIDER); export interface OpenCodeAdapterLiveOptions { readonly manager?: OpenCodeServerManager; readonly makeManager?: () => OpenCodeServerManager; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toSessionError(threadId: string, cause: unknown) { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown opencode session") || normalized.includes("unknown session")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError(threadId: string, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - export function makeOpenCodeAdapterLive(options: OpenCodeAdapterLiveOptions = {}) { return Layer.effect( OpenCodeAdapter, Effect.gen(function* () { const manager = options.manager ?? options.makeManager?.() ?? new OpenCodeServerManager(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; yield* Effect.acquireRelease( Effect.sync(() => { @@ -85,9 +45,35 @@ export function makeOpenCodeAdapterLive(options: OpenCodeAdapterLiveOptions = {} provider: PROVIDER, capabilities: getProviderCapabilities(PROVIDER), startSession: (input) => - Effect.tryPromise({ - try: () => manager.startSession(input), - catch: (cause) => toRequestError(input.threadId, "session/start", cause), + Effect.gen(function* () { + const providerSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((s) => s.providers.opencode), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + if (!providerSettings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "OpenCode provider is disabled in server settings.", + }); + } + const binaryPath = providerSettings.binaryPath.trim() || "opencode"; + return yield* Effect.tryPromise({ + try: () => + manager.startSession({ + ...input, + opencode: { binaryPath }, + } as OpenCodeSessionStartInput), + catch: (cause) => toRequestError(input.threadId, "session/start", cause), + }); }), sendTurn: (input) => { if ((input.attachments?.length ?? 0) > 0) { diff --git a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts index 1ef7bf40a6..b5899b7faa 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts @@ -56,6 +56,7 @@ const copilotLayer = makeCopilotAdapterLive({ }) as never, }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -79,15 +80,18 @@ const claudeLayer = makeClaudeAdapterLive({ const cursorLayer = makeCursorAdapterLive({ createProcess: () => ({}) as never, -}).pipe(Layer.provideMerge(NodeServices.layer)); +}).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), +); const geminiLayer = makeGeminiCliAdapterLive({ manager: new GeminiCliServerManager(), -}); +}).pipe(Layer.provideMerge(ServerSettingsService.layerTest())); const ampLayer = makeAmpAdapterLive({ manager: new AmpServerManager(), -}); +}).pipe(Layer.provideMerge(ServerSettingsService.layerTest())); describe("provider adapter conformance", () => { const cases = [ diff --git a/apps/server/src/provider/Layers/ProviderAdapterUtils.ts b/apps/server/src/provider/Layers/ProviderAdapterUtils.ts new file mode 100644 index 0000000000..2eaa3a8e78 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAdapterUtils.ts @@ -0,0 +1,149 @@ +/** + * Shared utilities for provider adapter implementations. + * + * Centralises common error-mapping and type-narrowing helpers that were + * previously duplicated across every adapter layer. + * + * @module ProviderAdapterUtils + */ + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + type ProviderAdapterError, +} from "../Errors.ts"; + +// Re-export toMessage so adapters can import everything from one place. +export { toMessage } from "../toMessage.ts"; +import { toMessage } from "../toMessage.ts"; + +// --------------------------------------------------------------------------- +// Error mapping helpers (parameterised by provider name) +// --------------------------------------------------------------------------- + +/** + * Inspect `cause` and return a session-level error when the message matches + * well-known "not found" / "closed" patterns for the given provider. + * + * Each provider historically checked for `"unknown session"` plus + * the generic `"unknown session"` string. Passing additional keywords via + * `extraSessionNotFoundHints` allows per-provider customisation without code + * duplication. + */ +export function toSessionError( + provider: string, + threadId: string, + cause: unknown, + options?: { + readonly sessionNotFoundHints?: ReadonlyArray; + readonly sessionClosedHint?: string; + }, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + + const notFoundHints: ReadonlyArray = options?.sessionNotFoundHints ?? [ + `unknown ${provider} session`, + "unknown session", + ]; + + if (notFoundHints.some((hint) => normalized.includes(hint))) { + return new ProviderAdapterSessionNotFoundError({ + provider, + threadId, + cause, + }); + } + + const closedHint = options?.sessionClosedHint ?? "closed"; + if (normalized.includes(closedHint)) { + return new ProviderAdapterSessionClosedError({ + provider, + threadId, + cause, + }); + } + + return undefined; +} + +/** + * Map an unknown `cause` into a typed `ProviderAdapterError`. + * + * Delegates to {@link toSessionError} first; falls back to a generic + * {@link ProviderAdapterRequestError}. + */ +export function toRequestError( + provider: string, + threadId: string, + method: string, + cause: unknown, + sessionErrorOptions?: Parameters[3], +): ProviderAdapterError { + const sessionError = toSessionError(provider, threadId, cause, sessionErrorOptions); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +// --------------------------------------------------------------------------- +// Factory: bind error helpers to a specific provider +// --------------------------------------------------------------------------- + +export interface BoundErrorHelpers { + readonly toSessionError: ( + threadId: string, + cause: unknown, + ) => ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined; + readonly toRequestError: ( + threadId: string, + method: string, + cause: unknown, + ) => ProviderAdapterError; +} + +/** + * Return `toSessionError` / `toRequestError` pre-bound to a specific provider + * name so that call sites keep their original `(threadId, method, cause)` + * signatures. + */ +export function makeErrorHelpers( + provider: string, + sessionErrorOptions?: Parameters[3], +): BoundErrorHelpers { + return { + toSessionError: (threadId, cause) => + toSessionError(provider, threadId, cause, sessionErrorOptions), + toRequestError: (threadId, method, cause) => + toRequestError(provider, threadId, method, cause, sessionErrorOptions), + }; +} + +// --------------------------------------------------------------------------- +// Type-narrowing helpers +// --------------------------------------------------------------------------- + +export function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a2647b5a8..a737bbf0c4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -134,7 +134,7 @@ import { getProviderModels, resolveSelectableProvider, } from "../providerModels"; -import { getCustomModelsByProvider, resolveAppModelSelection } from "../modelSelection"; +import { getCustomModelsByProvider, resolveAppModelSelection } from "../customModels"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -162,7 +162,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { - getCustomModelOptionsByProvider, + buildModelOptionsByProvider, mergeDiscoveredModels, ProviderModelPicker, } from "./chat/ProviderModelPicker"; @@ -715,7 +715,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const ampModelsQuery = useQuery(providerListModelsQueryOptions("amp")); const modelOptionsByProvider = useMemo( () => - mergeDiscoveredModels(getCustomModelOptionsByProvider(appSettings), { + mergeDiscoveredModels(buildModelOptionsByProvider(appSettings), { copilot: copilotModelsQuery.data, cursor: cursorModelsQuery.data, opencode: opencodeModelsQuery.data, @@ -3995,7 +3995,6 @@ export default function ChatView({ threadId }: ChatViewProps) { provider={selectedProvider} model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} - providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} {...(composerProviderState.modelPickerIconClassName ? { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 14472e30cd..f150d241e1 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -63,12 +63,12 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, - copilot: { enabled: true, customModels: [] }, - cursor: { enabled: true, customModels: [] }, - opencode: { enabled: true, customModels: [] }, - geminiCli: { enabled: true, customModels: [] }, - amp: { enabled: true, customModels: [] }, - kilo: { enabled: true, customModels: [] }, + copilot: { enabled: true, customModels: [], binaryPath: "" }, + cursor: { enabled: true, customModels: [], binaryPath: "" }, + opencode: { enabled: true, customModels: [], binaryPath: "" }, + geminiCli: { enabled: true, customModels: [], binaryPath: "" }, + amp: { enabled: true, customModels: [], binaryPath: "" }, + kilo: { enabled: true, customModels: [], binaryPath: "" }, }, }, }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4003ed9758..1f560aa366 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -117,6 +117,7 @@ import { import { ProviderLogo } from "./ProviderLogo"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useAppSettings } from "~/appSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -567,7 +568,7 @@ function ProviderUsageSection() { }); }, []); - const usageSettings = useSettings(); + const { settings: forkSettings } = useAppSettings(); const copilotUsage = useProviderUsage("copilot"); const codexUsage = useProviderUsage("codex"); @@ -591,11 +592,7 @@ function ProviderUsageSection() { const showCount = provider === "copilot"; const hidePlanLabel = provider === "copilot"; const hidePercentLabel = false; - const accentMap = (usageSettings as Record)["providerAccentColors"]; - const providerColor = - accentMap && typeof accentMap === "object" - ? ((accentMap as Record)[provider] ?? null) - : null; + const providerColor = forkSettings.providerAccentColors[provider] ?? null; const colorProp = providerColor ? { accentColor: providerColor } : {}; // Multiple quotas (e.g. Codex session + weekly) if (data?.quotas && data.quotas.length > 0) { @@ -809,6 +806,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const appSettings = useSettings(); + const { settings: forkAppSettings } = useAppSettings(); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ @@ -1727,7 +1725,7 @@ export default function Sidebar() { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 41517472d6..386f717cd1 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,125 +1,20 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ProviderKind } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { ProviderModelPicker, getCustomModelOptionsByProvider } from "./ProviderModelPicker"; - -function effort(value: string, isDefault = false) { - return { - value, - label: value, - ...(isDefault ? { isDefault: true } : {}), - }; -} - -const TEST_PROVIDERS: ReadonlyArray = [ - { - provider: "codex", - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - authStatus: "authenticated", - checkedAt: new Date().toISOString(), - models: [ - { - slug: "gpt-5-codex", - name: "GPT-5 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, - { - provider: "claudeAgent", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - authStatus: "authenticated", - checkedAt: new Date().toISOString(), - models: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, -]; +import { ProviderModelPicker, buildModelOptionsByProvider } from "./ProviderModelPicker"; async function mountPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; - providers?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); document.body.append(host); const onProviderModelChange = vi.fn(); - const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = getCustomModelOptionsByProvider({ + const modelOptionsByProvider = buildModelOptionsByProvider({ customCodexModels: [], customCopilotModels: [], customClaudeModels: [], @@ -134,7 +29,6 @@ async function mountPicker(props: { provider={props.provider} model={props.model} lockedProvider={props.lockedProvider} - providers={providers} modelOptionsByProvider={modelOptionsByProvider} triggerVariant={props.triggerVariant} onProviderModelChange={onProviderModelChange} @@ -267,39 +161,7 @@ describe("ProviderModelPicker", () => { // Fork: picker uses static PROVIDER_OPTIONS, not ServerProvider data, // so the disabled-provider rendering from upstream is not yet wired. - it.skip("shows disabled providers as non-selectable entries", async () => { - const disabledProviders = TEST_PROVIDERS.slice(); - const claudeIndex = disabledProviders.findIndex( - (provider) => provider.provider === "claudeAgent", - ); - if (claudeIndex >= 0) { - const claudeProvider = disabledProviders[claudeIndex]!; - disabledProviders[claudeIndex] = { - ...claudeProvider, - enabled: false, - status: "disabled", - }; - } - const mounted = await mountPicker({ - provider: "codex", - model: "gpt-5-codex", - lockedProvider: null, - providers: disabledProviders, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude"); - expect(text).toContain("Disabled"); - expect(text).not.toContain("Claude Sonnet 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); + // Test removed: providers prop was dead code and has been cleaned up. it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 1e8bc3e801..41f5f02a94 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { normalizeModelSlug, parseCursorModelSelection, @@ -52,7 +52,7 @@ type GroupedModelEntry = { readonly connected: boolean; }; -export function getCustomModelOptionsByProvider(settings: { +export function buildModelOptionsByProvider(settings: { customCodexModels: readonly string[]; customCopilotModels: readonly string[]; customClaudeModels: readonly string[]; @@ -247,7 +247,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; - providers?: ReadonlyArray; modelOptionsByProvider: Record>; ultrathinkActive?: boolean; activeProviderIconClassName?: string; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 868ddda11e..dbc5b42d8b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -32,7 +32,7 @@ import { import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { ensureNativeApi } from "~/nativeApi"; import { useLocalStorage } from "./useLocalStorage"; -import { normalizeCustomModelSlugs } from "~/modelSelection"; +import { normalizeCustomModelSlugs } from "~/customModels"; import { Predicate, Schema, Struct } from "effect"; import { DeepMutable } from "effect/Types"; import { deepMerge } from "@t3tools/shared/Struct"; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts deleted file mode 100644 index 860d8a0600..0000000000 --- a/apps/web/src/modelSelection.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Re-export model selection utilities from customModels where the fork -// maintains the canonical 8-provider implementations. Upstream introduced -// this module with only codex + claudeAgent; the fork keeps the full set in -// customModels.ts to avoid duplication. -export { - type AppModelOption, - type ProviderCustomModelConfig, - MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - normalizeCustomModelSlugs, - getCustomModelsForProvider, - getDefaultCustomModelsForProvider, - patchCustomModels, - getCustomModelsByProvider, - getAppModelOptions, - resolveAppModelSelection, - getCustomModelOptionsByProvider, -} from "./customModels"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 52c6896292..d3944c4e8a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -80,42 +80,42 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const CopilotModelSelection = Schema.Struct({ provider: Schema.Literal("copilot"), model: TrimmedNonEmptyString, - options: Schema.optional(CopilotModelOptions), + options: Schema.optionalKey(CopilotModelOptions), }); export type CopilotModelSelection = typeof CopilotModelSelection.Type; export const CursorModelSelection = Schema.Struct({ provider: Schema.Literal("cursor"), model: TrimmedNonEmptyString, - options: Schema.optional(CursorModelOptions), + options: Schema.optionalKey(CursorModelOptions), }); export type CursorModelSelection = typeof CursorModelSelection.Type; export const OpencodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, - options: Schema.optional(OpencodeModelOptions), + options: Schema.optionalKey(OpencodeModelOptions), }); export type OpencodeModelSelection = typeof OpencodeModelSelection.Type; export const GeminiCliModelSelection = Schema.Struct({ provider: Schema.Literal("geminiCli"), model: TrimmedNonEmptyString, - options: Schema.optional(GeminiCliModelOptions), + options: Schema.optionalKey(GeminiCliModelOptions), }); export type GeminiCliModelSelection = typeof GeminiCliModelSelection.Type; export const AmpModelSelection = Schema.Struct({ provider: Schema.Literal("amp"), model: TrimmedNonEmptyString, - options: Schema.optional(AmpModelOptions), + options: Schema.optionalKey(AmpModelOptions), }); export type AmpModelSelection = typeof AmpModelSelection.Type; export const KiloModelSelection = Schema.Struct({ provider: Schema.Literal("kilo"), model: TrimmedNonEmptyString, - options: Schema.optional(KiloModelOptions), + options: Schema.optionalKey(KiloModelOptions), }); export type KiloModelSelection = typeof KiloModelSelection.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0147b76857..3e327c27cf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -79,6 +79,7 @@ export type ClaudeSettings = typeof ClaudeSettings.Type; export const GenericProviderSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + binaryPath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), }); export type GenericProviderSettings = typeof GenericProviderSettings.Type;