From 9b0d4fc8497838ecade633e4605e948279346182 Mon Sep 17 00:00:00 2001 From: Ashik Chapagain Date: Sun, 8 Mar 2026 18:56:26 +0545 Subject: [PATCH 1/4] Add Claude provider support across server, contracts, and web - add Claude adapter/service and register it alongside Codex - extend provider/runtime schemas, health checks, and orchestration flow for Claude - update web settings/session logic to handle Claude as a selectable provider - expand tests to cover Claude provider registry and runtime ingestion --- .../Layers/ProviderCommandReactor.ts | 4 +- .../Layers/ProviderRuntimeIngestion.test.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.ts | 875 ++++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 15 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 115 ++- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/ClaudeAdapter.ts | 17 + apps/server/src/serverLayers.ts | 5 + apps/web/src/appSettings.ts | 5 + apps/web/src/components/ChatView.tsx | 51 +- apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 14 + apps/web/src/session-logic.test.ts | 10 +- apps/web/src/session-logic.ts | 4 +- apps/web/src/store.ts | 9 +- packages/contracts/src/model.ts | 23 + packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/provider.test.ts | 22 + packages/contracts/src/provider.ts | 5 + packages/contracts/src/providerRuntime.ts | 1 + packages/shared/src/model.test.ts | 15 + packages/shared/src/model.ts | 1 + 23 files changed, 1175 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeAdapter.ts create mode 100644 apps/server/src/provider/Services/ClaudeAdapter.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc2..573a5d9fa 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -213,7 +213,9 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || thread.session?.providerName === "claude" + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846..1e963be16 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts new file mode 100644 index 000000000..aa3024723 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -0,0 +1,875 @@ +import { spawn } from "node:child_process"; + +import { + type CanonicalItemType, + EventId, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; + +const PROVIDER = "claude" as const; + +export interface ClaudeAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface ClaudeToolState { + readonly itemId: ProviderItemId; + readonly itemType: CanonicalItemType; + readonly title: string; +} + +interface ClaudeTurnState { + readonly turnId: TurnId; + readonly child: ReturnType; + readonly toolStates: Map; + assistantItemId?: ProviderItemId; + completed: boolean; + interrupted: boolean; +} + +interface ClaudeSessionState { + session: ProviderSession; + readonly binaryPath: string; + hasConversation: boolean; + currentTurn: ClaudeTurnState | null; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function randomEventId(): ReturnType { + return EventId.makeUnsafe(crypto.randomUUID()); +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function coerceDetail(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + if (Array.isArray(value)) { + const joined = value + .map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))) + .join("\n") + .trim(); + return joined.length > 0 ? joined : undefined; + } + + if (value !== undefined) { + try { + const serialized = JSON.stringify(value); + return serialized.length > 2 ? serialized : undefined; + } catch { + return undefined; + } + } + + return undefined; +} + +function toValidationError(operation: string, issue: string, cause?: unknown) { + return new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function toRequestError(method: string, detail: string, cause?: unknown) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function toProcessError(threadId: ThreadId, detail: string, cause?: unknown) { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function buildSession(input: { + readonly threadId: ThreadId; + readonly cwd?: string; + readonly model?: string; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly sessionId: string; +}): ProviderSession { + const createdAt = nowIso(); + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + threadId: input.threadId, + resumeCursor: input.sessionId, + createdAt, + updatedAt: createdAt, + }; +} + +function permissionModeFor(input: { + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly interactionMode: ProviderSendTurnInput["interactionMode"]; +}): "default" | "bypassPermissions" | "plan" { + if (input.interactionMode === "plan") { + return "plan"; + } + return input.runtimeMode === "approval-required" ? "default" : "bypassPermissions"; +} + +function toolItemType(name: string): CanonicalItemType { + const normalized = name.trim().toLowerCase(); + if (normalized === "bash") return "command_execution"; + if (normalized === "edit" || normalized === "write" || normalized === "notebookedit") + return "file_change"; + if (normalized === "websearch" || normalized === "webfetch") return "web_search"; + if (normalized.startsWith("mcp__")) return "mcp_tool_call"; + return "dynamic_tool_call"; +} + +function toolTitle(name: string): string { + switch (toolItemType(name)) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "web_search": + return "Web search"; + case "mcp_tool_call": + return "MCP tool call"; + default: + return name.trim() || "Tool call"; + } +} + +function toolDetail(name: string, input: unknown): string | undefined { + const record = asRecord(input); + const detail = + asString(record?.command) ?? + asString(record?.description) ?? + asString(record?.file_path) ?? + asString(record?.path) ?? + asString(record?.query); + + return detail ?? coerceDetail(input); +} + +function buildRaw(record: unknown, method: string): ProviderRuntimeEvent["raw"] { + return { + source: "claude.cli", + method, + payload: record, + }; +} + +function buildBaseEvent(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId; + readonly itemId?: ProviderItemId; + readonly raw?: ProviderRuntimeEvent["raw"]; +}) { + return { + eventId: randomEventId(), + provider: PROVIDER, + threadId: input.threadId, + createdAt: nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.makeUnsafe(input.itemId) } : {}), + ...(input.raw ? { raw: input.raw } : {}), + } satisfies Omit; +} + +export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { + return Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const sessions = new Map(); + const eventQueue = yield* Queue.unbounded(); + + const publish = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + if (nativeEventLogger) { + yield* nativeEventLogger.write(event.raw ?? event, event.threadId); + } + yield* Queue.offer(eventQueue, event).pipe(Effect.asVoid); + }); + + const publishFork = (event: ProviderRuntimeEvent) => { + void Effect.runFork(publish(event)); + }; + + const getSessionState = (threadId: ThreadId) => { + const state = sessions.get(threadId); + if (!state) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(state); + }; + + const emitSessionReady = (state: ClaudeSessionState) => + Effect.all([ + publish({ + ...buildBaseEvent({ + threadId: state.session.threadId, + raw: buildRaw( + { sessionId: state.session.resumeCursor, model: state.session.model }, + "session/started", + ), + }), + type: "session.started", + payload: { + message: "Claude Code session ready", + resume: state.session.resumeCursor, + }, + }), + publish({ + ...buildBaseEvent({ + threadId: state.session.threadId, + raw: buildRaw({ state: "ready" }, "session/state"), + }), + type: "session.state.changed", + payload: { + state: "ready", + reason: "Claude Code session ready", + }, + }), + ]).pipe(Effect.asVoid); + + const finalizeTurn = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly state: "completed" | "failed" | "interrupted"; + readonly stopReason?: string | null; + readonly usage?: unknown; + readonly modelUsage?: Record; + readonly totalCostUsd?: number; + readonly errorMessage?: string; + }) => + Effect.gen(function* () { + const state = yield* getSessionState(input.threadId); + state.currentTurn = null; + state.session = { + ...state.session, + status: input.state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: nowIso(), + ...(input.errorMessage ? { lastError: input.errorMessage } : {}), + }; + sessions.set(input.threadId, state); + + yield* publish({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId: input.turnId, + raw: buildRaw( + { + state: input.state, + stopReason: input.stopReason, + usage: input.usage, + modelUsage: input.modelUsage, + totalCostUsd: input.totalCostUsd, + errorMessage: input.errorMessage, + }, + "turn/completed", + ), + }), + type: "turn.completed", + payload: { + state: input.state, + ...(input.stopReason !== undefined ? { stopReason: input.stopReason } : {}), + ...(input.usage !== undefined ? { usage: input.usage } : {}), + ...(input.modelUsage !== undefined ? { modelUsage: input.modelUsage } : {}), + ...(input.totalCostUsd !== undefined ? { totalCostUsd: input.totalCostUsd } : {}), + ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), + }, + }); + + yield* publish({ + ...buildBaseEvent({ + threadId: input.threadId, + raw: buildRaw({ state: state.session.status }, "session/state"), + }), + type: "session.state.changed", + payload: { + state: input.state === "failed" ? "error" : "ready", + ...(input.errorMessage ? { reason: input.errorMessage } : {}), + }, + }); + }); + + const startSession: ClaudeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider && input.provider !== PROVIDER) { + return yield* toValidationError( + "ClaudeAdapter.startSession", + `Expected provider '${PROVIDER}', received '${input.provider}'.`, + ); + } + + const resumeCursor = + typeof input.resumeCursor === "string" && input.resumeCursor.trim().length > 0 + ? input.resumeCursor.trim() + : crypto.randomUUID(); + + const binaryPath = + input.providerOptions?.claude?.binaryPath?.trim() || "claude"; + const session = buildSession({ + threadId: input.threadId, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + runtimeMode: input.runtimeMode, + sessionId: resumeCursor, + }); + + const existing = sessions.get(input.threadId); + if (existing?.currentTurn) { + existing.currentTurn.interrupted = true; + existing.currentTurn.child.kill("SIGTERM"); + } + + const nextState: ClaudeSessionState = { + session, + binaryPath, + hasConversation: input.resumeCursor !== undefined, + currentTurn: null, + }; + sessions.set(input.threadId, nextState); + yield* emitSessionReady(nextState); + return session; + }); + + const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + if (input.attachments && input.attachments.length > 0) { + return yield* toValidationError( + "ClaudeAdapter.sendTurn", + "Claude Code CLI image attachments are not supported yet.", + ); + } + + const state = yield* getSessionState(input.threadId); + if (state.currentTurn) { + return yield* toRequestError( + "claude.sendTurn", + `A Claude turn is already running for thread '${input.threadId}'.`, + ); + } + + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + const sessionId = String(state.session.resumeCursor ?? crypto.randomUUID()); + const model = input.model ?? state.session.model; + const args = [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--permission-mode", + permissionModeFor({ + runtimeMode: state.session.runtimeMode, + interactionMode: input.interactionMode, + }), + ]; + + if (model) { + args.push("--model", model); + } + + if (state.hasConversation) { + args.push("--resume", sessionId); + } else { + args.push("--session-id", sessionId); + } + + if (input.input) { + args.push(input.input); + } + + const child = yield* Effect.try({ + try: () => + spawn(state.binaryPath, args, { + cwd: state.session.cwd ?? process.cwd(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }), + catch: (cause) => + toProcessError( + input.threadId, + `Failed to spawn Claude Code CLI '${state.binaryPath}'.`, + cause, + ), + }); + + const turnState: ClaudeTurnState = { + turnId, + child, + toolStates: new Map(), + completed: false, + interrupted: false, + }; + state.currentTurn = turnState; + state.session = { + ...state.session, + ...(model ? { model } : {}), + status: "running", + activeTurnId: turnId, + updatedAt: nowIso(), + }; + state.session = { + ...state.session, + resumeCursor: sessionId, + }; + sessions.set(input.threadId, state); + + yield* publish({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + raw: buildRaw({ model }, "turn/started"), + }), + type: "turn.started", + payload: model ? { model } : {}, + }); + + yield* publish({ + ...buildBaseEvent({ + threadId: input.threadId, + raw: buildRaw({ state: "running" }, "session/state"), + }), + type: "session.state.changed", + payload: { + state: "running", + reason: "Claude Code turn started", + }, + }); + + let stdoutBuffer = ""; + let stderrBuffer = ""; + + const processClaudeRecord = (record: Record) => { + const type = asString(record.type); + if (!type) { + return; + } + + if (type === "stream_event") { + const event = asRecord(record.event); + const eventType = asString(event?.type); + if (eventType === "message_start") { + const message = asRecord(event?.message); + const messageId = asString(message?.id); + if (messageId) { + turnState.assistantItemId = ProviderItemId.makeUnsafe(messageId); + } + return; + } + + if (eventType === "content_block_delta") { + const delta = asRecord(event?.delta); + const deltaType = asString(delta?.type); + if (deltaType === "text_delta" && turnState.assistantItemId) { + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + itemId: turnState.assistantItemId, + raw: buildRaw(record, "stream_event/content_block_delta"), + }), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: asString(delta?.text) ?? "", + }, + }); + } + return; + } + + return; + } + + if (type === "assistant") { + const message = asRecord(record.message); + const messageId = asString(message?.id); + const content = Array.isArray(message?.content) ? message.content : []; + for (const block of content) { + const contentBlock = asRecord(block); + const contentType = asString(contentBlock?.type); + if (contentType === "thinking") { + const thinking = asString(contentBlock?.thinking); + if (thinking) { + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + raw: buildRaw(record, "assistant/thinking"), + }), + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(`claude-thinking:${turnId}`), + description: thinking, + }, + }); + } + continue; + } + + if (contentType === "tool_use") { + const toolId = asString(contentBlock?.id); + const toolName = asString(contentBlock?.name) ?? "Tool"; + if (!toolId || turnState.toolStates.has(toolId)) { + continue; + } + const itemId = ProviderItemId.makeUnsafe(toolId); + const nextToolState: ClaudeToolState = { + itemId, + itemType: toolItemType(toolName), + title: toolTitle(toolName), + }; + turnState.toolStates.set(toolId, nextToolState); + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + itemId, + raw: buildRaw(record, `assistant/tool_use/${toolName}`), + }), + type: "item.started", + payload: { + itemType: nextToolState.itemType, + status: "inProgress", + title: nextToolState.title, + ...(toolDetail(toolName, contentBlock?.input) + ? { detail: toolDetail(toolName, contentBlock?.input) } + : {}), + data: contentBlock, + }, + }); + continue; + } + + if (contentType === "text") { + const text = asString(contentBlock?.text); + const itemId = messageId + ? ProviderItemId.makeUnsafe(messageId) + : turnState.assistantItemId; + if (text && itemId) { + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + itemId, + raw: buildRaw(record, "assistant/message"), + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + detail: text, + data: contentBlock, + }, + }); + } + } + } + return; + } + + if (type === "user") { + const message = asRecord(record.message); + const content = Array.isArray(message?.content) ? message.content : []; + for (const block of content) { + const contentBlock = asRecord(block); + if (asString(contentBlock?.type) !== "tool_result") { + continue; + } + const toolUseId = asString(contentBlock?.tool_use_id); + if (!toolUseId) { + continue; + } + const toolState = turnState.toolStates.get(toolUseId); + const itemId = toolState?.itemId ?? ProviderItemId.makeUnsafe(toolUseId); + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + itemId, + raw: buildRaw(record, "user/tool_result"), + }), + type: "item.completed", + payload: { + itemType: toolState?.itemType ?? "dynamic_tool_call", + status: contentBlock?.is_error === true ? "failed" : "completed", + title: toolState?.title ?? "Tool call", + ...(coerceDetail(contentBlock?.content) + ? { detail: coerceDetail(contentBlock?.content) } + : {}), + data: contentBlock, + }, + }); + turnState.toolStates.delete(toolUseId); + } + return; + } + + if (type === "result") { + turnState.completed = true; + const permissionDenials = Array.isArray(record.permission_denials) + ? record.permission_denials + : []; + const errorMessage = + record.is_error === true + ? coerceDetail(record.errors) ?? asString(record.result) ?? "Claude turn failed" + : permissionDenials.length > 0 + ? `Claude denied ${permissionDenials.length} tool request(s).` + : undefined; + const stopReason = + asString(record.stop_reason) ?? + (record.subtype === "success" ? "end_turn" : asString(record.subtype)) ?? + null; + + void Effect.runFork( + finalizeTurn({ + threadId: input.threadId, + turnId, + state: errorMessage ? "failed" : "completed", + ...(stopReason !== null ? { stopReason } : {}), + ...(record.usage !== undefined ? { usage: record.usage } : {}), + ...(asRecord(record.modelUsage) ? { modelUsage: record.modelUsage as Record } : {}), + ...(typeof record.total_cost_usd === "number" + ? { totalCostUsd: record.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to finalize Claude turn", { + threadId: input.threadId, + error, + }), + ), + ), + ); + } + }; + + const drainStdout = (chunk: Buffer) => { + stdoutBuffer += chunk.toString("utf8"); + while (true) { + const newlineIndex = stdoutBuffer.indexOf("\n"); + if (newlineIndex === -1) { + return; + } + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + try { + const record = JSON.parse(line) as Record; + processClaudeRecord(record); + } catch (error) { + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + raw: buildRaw({ line }, "stdout/parse-error"), + }), + type: "runtime.warning", + payload: { + message: "Received invalid JSON from Claude Code CLI.", + detail: error instanceof Error ? error.message : String(error), + }, + }); + } + } + }; + + child.stdout.on("data", drainStdout); + child.stderr.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString("utf8"); + }); + child.once("error", (error) => { + publishFork({ + ...buildBaseEvent({ + threadId: input.threadId, + turnId, + raw: buildRaw({ error: String(error) }, "process/error"), + }), + type: "runtime.error", + payload: { + message: `Claude Code process error: ${error.message}`, + class: "provider_error", + }, + }); + }); + child.once("exit", (code, signal) => { + if (turnState.completed) { + const current = sessions.get(input.threadId); + if (current) { + current.hasConversation = true; + sessions.set(input.threadId, current); + } + return; + } + + const interrupted = turnState.interrupted; + const errorMessage = + interrupted + ? "Claude turn interrupted." + : stderrBuffer.trim().length > 0 + ? stderrBuffer.trim() + : `Claude Code exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"}).`; + + void Effect.runFork( + finalizeTurn({ + threadId: input.threadId, + turnId, + state: interrupted ? "interrupted" : "failed", + ...(interrupted ? {} : { errorMessage }), + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to finalize Claude turn after exit", { + threadId: input.threadId, + error, + }), + ), + ), + ); + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: sessionId, + }; + }); + + const interruptTurn: ClaudeAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const state = yield* getSessionState(threadId); + if (!state.currentTurn) { + return; + } + state.currentTurn.interrupted = true; + state.currentTurn.child.kill("SIGTERM"); + }); + + const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const state = yield* getSessionState(threadId); + if (state.currentTurn) { + state.currentTurn.interrupted = true; + state.currentTurn.child.kill("SIGTERM"); + } + sessions.delete(threadId); + yield* publish({ + ...buildBaseEvent({ + threadId, + raw: buildRaw({ exitKind: "graceful" }, "session/exited"), + }), + type: "session.exited", + payload: { + exitKind: "graceful", + }, + }); + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + Effect.forEach([...sessions.keys()], (threadId) => stopSession(threadId)).pipe(Effect.asVoid); + + const unsupportedRequest = (method: string, detail: string) => + Effect.fail(toRequestError(method, detail)); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest: () => + unsupportedRequest( + "claude.respondToRequest", + "Claude Code CLI approval responses are not supported in print mode.", + ), + respondToUserInput: () => + unsupportedRequest( + "claude.respondToUserInput", + "Claude Code CLI structured user input is not supported in print mode.", + ), + stopSession, + listSessions: () => Effect.sync(() => [...sessions.values()].map((state) => state.session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), + readThread: (threadId) => + Effect.sync( + () => + ({ + threadId, + turns: [], + }) satisfies ProviderThreadSnapshot, + ), + rollbackThread: (threadId) => + unsupportedRequest( + "claude.rollbackThread", + `Claude Code CLI does not support rolling back thread '${threadId}'.`, + ) as Effect.Effect, + stopAll, + streamEvents: Stream.fromQueue(eventQueue), + } satisfies ClaudeAdapterShape; + }), + ); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a50112a62..baa0c26e4 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 { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,11 +28,19 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeAdapter: ClaudeAdapterShape = { + ...fakeCodexAdapter, + provider: "claude", +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( ProviderAdapterRegistryLive, - Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + ), ), NodeServices.layer, ), @@ -43,9 +52,11 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); assert.equal(codex, fakeCodexAdapter); + const claude = yield* registry.getByProvider("claude"); + assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claude"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c7..d7d7bf145 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 { ClaudeAdapter } from "../Services/ClaudeAdapter.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* ClaudeAdapter]; 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 59f41edf8..8d71af390 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -25,6 +25,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const CLAUDE_PROVIDER = "claude" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -45,7 +46,9 @@ function isCommandMissingCause(error: unknown): boolean { const lower = error.message.toLowerCase(); return ( lower.includes("command not found: codex") || + lower.includes("command not found: claude") || lower.includes("spawn codex enoent") || + lower.includes("spawn claude enoent") || lower.includes("enoent") || lower.includes("notfound") ); @@ -177,9 +180,15 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. ); const runCodexCommand = (args: ReadonlyArray) => + runProviderCommand("codex", args); + +const runClaudeCommand = (args: ReadonlyArray) => + runProviderCommand("claude", args); + +const runProviderCommand = (commandName: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const command = ChildProcess.make(commandName, [...args], { shell: process.platform === "win32", }); @@ -307,14 +316,116 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkClaudeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? "Claude Code CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Code CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Claude Code CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Claude Code CLI is installed but failed to run. ${detail}` + : "Claude Code CLI is installed but failed to run.", + }; + } + + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude Code authentication status: ${error.message}.` + : "Could not verify Claude Code authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + "Could not verify Claude Code authentication status. Timed out while running command.", + }; + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message + ? { + message: parsed.message + .replaceAll("Codex CLI", "Claude Code CLI") + .replaceAll("codex login", "claude auth login"), + } + : {}), + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { const codexStatus = yield* checkCodexProviderStatus; + const claudeStatus = yield* checkClaudeProviderStatus; return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([codexStatus, claudeStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439b..fa0dc4964 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 === "claude") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts new file mode 100644 index 000000000..f00f52b1c --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -0,0 +1,17 @@ +/** + * ClaudeAdapter - Claude Code CLI implementation of the generic provider adapter contract. + * + * @module ClaudeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface ClaudeAdapterShape extends ProviderAdapterShape { + readonly provider: "claude"; +} + +export class ClaudeAdapter extends ServiceMap.Service()( + "t3/provider/Services/ClaudeAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b..a9521cdac 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 { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; 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 claudeAdapterLayer = makeClaudeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provideMerge(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a..2df704757 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)), + claude: new Set(getModelOptions("claude").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -45,6 +46,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -108,6 +112,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claude"), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f419..74c10d41c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -803,7 +803,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = getCustomModelsForProvider(settings, selectedProvider); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -3067,7 +3067,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection(provider, getCustomModelsForProvider(settings, provider), model), ); scheduleComposerFocus(); }, @@ -3077,7 +3077,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, + settings, ], ); const onEffortSelect = useCallback( @@ -4096,17 +4096,18 @@ const ProviderHealthBanner = memo(function ProviderHealthBanner({ return null; } + const providerLabel = status.provider === "codex" ? "Codex" : "Claude Code"; const defaultMessage = status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; + ? `${providerLabel} provider is unavailable.` + : `${providerLabel} provider has limited availability.`; return (
- {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} + {providerLabel} provider status {status.message ?? defaultMessage} @@ -5285,7 +5286,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); @@ -5297,15 +5298,27 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claude: getAppModelOptions("claude", settings.customClaudeModels), }; } +function getCustomModelsForProvider( + settings: { + customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + }, + provider: ProviderKind, +): readonly string[] { + return provider === "claude" ? settings.customClaudeModels : settings.customCodexModels; +} + const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, - claudeCode: ClaudeAI, + claude: ClaudeAI, cursor: CursorIcon, }; @@ -5356,6 +5369,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + const lockedProviderLabel = + props.lockedProvider === "codex" + ? "Codex" + : props.lockedProvider === "claude" + ? "Claude Code" + : null; return ( + {lockedProviderLabel ? ( + <> +
+ This thread is locked to {lockedProviderLabel}. Start a new thread to switch + providers. +
+ + + ) : null} {AVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const isDisabledByProviderLock = @@ -5400,6 +5428,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { className="size-4 shrink-0 text-muted-foreground/85" /> {option.label} + {isDisabledByProviderLock ? ( + + New thread + + ) : null} @@ -5447,7 +5480,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { aria-hidden="true" className={cn( "size-4 shrink-0 opacity-80", - option.value === "claudeCode" ? "" : "text-muted-foreground/85", + option.value === "claude" ? "" : "text-muted-foreground/85", )} /> {option.label} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed..88de1e023 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 === "claude" ? 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 cc4a39a27..d0d58fc24 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: "claude", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-4-6", + }, ] as const; function getCustomModelsForProvider( @@ -61,6 +68,8 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claude": + return settings.customClaudeModels; case "codex": default: return settings.customCodexModels; @@ -72,6 +81,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claude": + return defaults.customClaudeModels; case "codex": default: return defaults.customCodexModels; @@ -80,6 +91,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "claude": + return { customClaudeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -96,6 +109,7 @@ function SettingsRouteView() { Record >({ codex: "", + claude: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d4..29e929c9a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -640,18 +640,18 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { - const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); + it("lists Claude Code as an available provider and Cursor as coming soon", () => { + const claude = PROVIDER_OPTIONS.find((option) => option.value === "claude"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claude", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ - value: "claudeCode", + value: "claude", label: "Claude Code", - available: false, + available: true, }); expect(cursor).toEqual({ value: "cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b..acdd5f868 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -10,7 +10,7 @@ import { import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -18,7 +18,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claude", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c966537..b06cb10b2 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 === "claude") { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claude").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 === "claude") { return input.sessionProviderName; } + const normalizedClaude = normalizeModelSlug(input.model, "claude"); + if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { + return "claude"; + } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..386851de3 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -10,8 +10,12 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ClaudeModelOptions = Schema.Struct({}); +export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + claude: Schema.optional(ClaudeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +32,18 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claude: [ + { slug: "default", name: "Default" }, + { slug: "sonnet", name: "Claude Sonnet 4.6" }, + { slug: "opus", name: "Claude Opus 4.6" }, + { slug: "haiku", name: "Claude Haiku 4.5" }, + { slug: "sonnet[1m]", name: "Claude Sonnet 4.6 (1M Context Beta)" }, + { slug: "opusplan", name: "Claude Opus Plan" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Pinned)" }, + { slug: "claude-opus-4-6", name: "Claude Opus 4.6 (Pinned)" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Pinned)" }, + { slug: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (2025-10-01)" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +52,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + claude: "default", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +63,18 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + claude: { + "claude-sonnet-4-5": "sonnet", + "claude-opus-4-1": "opus", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + claude: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + claude: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa7bd827d..ecb31717d 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.Literals(["codex", "claude"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b7..7df17ca1f 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -61,4 +61,26 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); expect(parsed.modelOptions?.codex?.fastMode).toBe(true); }); + + it("accepts claude-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-2", + provider: "claude", + cwd: "/tmp/workspace", + model: "sonnet", + modelOptions: { + claude: {}, + }, + runtimeMode: "approval-required", + providerOptions: { + claude: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + + expect(parsed.provider).toBe("claude"); + expect(parsed.model).toBe("sonnet"); + expect(parsed.providerOptions?.claude?.binaryPath).toBe("/usr/local/bin/claude"); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9ca7068ad..d52d5b51e 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -53,8 +53,13 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const ClaudeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claude: Schema.optional(ClaudeProviderStartOptions), }); export const ProviderSessionStartInput = Schema.Struct({ diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 903bb5da7..7ef504c33 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -20,6 +20,7 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.request", "codex.eventmsg", "codex.sdk.thread-event", + "claude.cli", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1..df07fccc0 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,6 +14,7 @@ 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("claude-opus-4-6", "claude")).toBe("opus"); }); it("returns null for empty or missing values", () => { @@ -26,6 +27,7 @@ describe("normalizeModelSlug", () => { it("preserves non-aliased model slugs", () => { expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); + expect(normalizeModelSlug("sonnet", "claude")).toBe("sonnet"); }); it("does not leak prototype properties as aliases", () => { @@ -49,21 +51,34 @@ describe("resolveModelSlug", () => { for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { expect(resolveModelSlug(model.slug)).toBe(model.slug); } + for (const model of MODEL_OPTIONS_BY_PROVIDER.claude) { + expect(resolveModelSlug(model.slug, "claude")).toBe(model.slug); + } }); it("keeps codex defaults for backward compatibility", () => { expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); expect(getModelOptions()).toEqual(MODEL_OPTIONS_BY_PROVIDER.codex); }); + + it("returns provider-scoped defaults for claude", () => { + expect(getDefaultModel("claude")).toBe(DEFAULT_MODEL_BY_PROVIDER.claude); + expect(getModelOptions("claude")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claude); + }); }); describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]); }); + + it("returns no reasoning options for claude", () => { + expect(getReasoningEffortOptions("claude")).toEqual([]); + }); }); describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("claude")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..c81f80880 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)), + claude: new Set(MODEL_OPTIONS_BY_PROVIDER.claude.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { From 2b0c043c5ef4a3c216ad00fe0e47c8ed9a94bb7f Mon Sep 17 00:00:00 2001 From: Ashik Chapagain Date: Sun, 8 Mar 2026 19:41:20 +0545 Subject: [PATCH 2/4] Handle missing custom model lists in persisted app settings - Default legacy settings snapshots to empty custom model arrays - Guard chat settings accessors against undefined custom model lists - Add regression test for loading persisted settings without Claude models --- apps/web/src/appSettings.test.ts | 57 +++++++++++++++++++++++++- apps/web/src/appSettings.ts | 12 +++--- apps/web/src/routes/_chat.settings.tsx | 8 ++-- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 5ab5d3c90..52450b922 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getAppModelOptions, + getAppSettingsSnapshot, getSlashModelOptions, normalizeCustomModelSlugs, resolveAppServiceTier, @@ -9,6 +10,60 @@ import { resolveAppModelSelection, } from "./appSettings"; +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +const originalWindow = globalThis.window; + +describe("getAppSettingsSnapshot", () => { + beforeEach(() => { + const storage = new Map(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + clear: () => { + storage.clear(); + }, + }, + }, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + }); + + it("preserves legacy persisted settings when claude models are missing", () => { + window.localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + confirmThreadDelete: false, + enableAssistantStreaming: true, + codexServiceTier: "fast", + customCodexModels: ["custom/internal-model"], + }), + ); + + expect(getAppSettingsSnapshot()).toMatchObject({ + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + confirmThreadDelete: false, + enableAssistantStreaming: true, + codexServiceTier: "fast", + customCodexModels: ["custom/internal-model"], + customClaudeModels: [], + }); + }); +}); + describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { expect( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 2df704757..e8e811596 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -30,6 +30,10 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record codex: new Set(getModelOptions("codex").map((option) => option.slug)), claude: new Set(getModelOptions("claude").map((option) => option.slug)), }; +const CustomModelListSchema = Schema.Array(Schema.String).pipe( + Schema.withDecodingDefaultKey(() => []), + Schema.withConstructorDefault(() => Option.some([])), +); const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( @@ -43,12 +47,8 @@ const AppSettingsSchema = Schema.Struct({ Schema.withConstructorDefault(() => Option.some(false)), ), codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), - customCodexModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customClaudeModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), + customCodexModels: CustomModelListSchema, + customClaudeModels: CustomModelListSchema, }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d0d58fc24..a1b1ff93d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -69,10 +69,10 @@ function getCustomModelsForProvider( ) { switch (provider) { case "claude": - return settings.customClaudeModels; + return settings.customClaudeModels ?? []; case "codex": default: - return settings.customCodexModels; + return settings.customCodexModels ?? []; } } @@ -82,10 +82,10 @@ function getDefaultCustomModelsForProvider( ) { switch (provider) { case "claude": - return defaults.customClaudeModels; + return defaults.customClaudeModels ?? []; case "codex": default: - return defaults.customCodexModels; + return defaults.customCodexModels ?? []; } } From 67d6951f178962545f5455752fc8047b54319fd0 Mon Sep 17 00:00:00 2001 From: Ashik Chapagain Date: Sun, 8 Mar 2026 19:46:55 +0545 Subject: [PATCH 3/4] Guard Claude session updates to the active turn - Ignore stale turn-complete events unless they match the current turn - Emit `session.state.changed` only for the active turn to prevent false transitions - Mark `hasConversation` on process exit only when resume cursor matches session --- .../src/provider/Layers/ClaudeAdapter.ts | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index aa3024723..831f2d605 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -299,15 +299,18 @@ export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { }) => Effect.gen(function* () { const state = yield* getSessionState(input.threadId); - state.currentTurn = null; - state.session = { - ...state.session, - status: input.state === "failed" ? "error" : "ready", - activeTurnId: undefined, - updatedAt: nowIso(), - ...(input.errorMessage ? { lastError: input.errorMessage } : {}), - }; - sessions.set(input.threadId, state); + const isCurrentTurn = state.currentTurn?.turnId === input.turnId; + if (isCurrentTurn) { + state.currentTurn = null; + state.session = { + ...state.session, + status: input.state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: nowIso(), + ...(input.errorMessage ? { lastError: input.errorMessage } : {}), + }; + sessions.set(input.threadId, state); + } yield* publish({ ...buildBaseEvent({ @@ -336,17 +339,19 @@ export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { }, }); - yield* publish({ - ...buildBaseEvent({ - threadId: input.threadId, - raw: buildRaw({ state: state.session.status }, "session/state"), - }), - type: "session.state.changed", - payload: { - state: input.state === "failed" ? "error" : "ready", - ...(input.errorMessage ? { reason: input.errorMessage } : {}), - }, - }); + if (isCurrentTurn) { + yield* publish({ + ...buildBaseEvent({ + threadId: input.threadId, + raw: buildRaw({ state: state.session.status }, "session/state"), + }), + type: "session.state.changed", + payload: { + state: input.state === "failed" ? "error" : "ready", + ...(input.errorMessage ? { reason: input.errorMessage } : {}), + }, + }); + } }); const startSession: ClaudeAdapterShape["startSession"] = (input) => @@ -758,7 +763,7 @@ export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { child.once("exit", (code, signal) => { if (turnState.completed) { const current = sessions.get(input.threadId); - if (current) { + if (current?.session.resumeCursor === sessionId) { current.hasConversation = true; sessions.set(input.threadId, current); } From 9bea35fba99f85e9eea898263950d64e720c3fc8 Mon Sep 17 00:00:00 2001 From: Ashik Chapagain Date: Sun, 8 Mar 2026 19:51:48 +0545 Subject: [PATCH 4/4] Add Claude stale-exit tests and provider-specific auth messaging - add ClaudeAdapter lifecycle tests for stale exit and resumability behavior - parameterize auth status parsing messages by provider context - verify Claude health checks report Claude-specific auth guidance --- .../src/provider/Layers/ClaudeAdapter.test.ts | 195 ++++++++++++++++++ .../provider/Layers/ProviderHealth.test.ts | 52 ++++- .../src/provider/Layers/ProviderHealth.ts | 46 +++-- 3 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeAdapter.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts new file mode 100644 index 000000000..53a94a96c --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { vi } from "vitest"; + +const mockState = vi.hoisted(() => { + class MockEmitter { + private readonly listeners = new Map) => void>>(); + + on(event: string, listener: (...args: Array) => void) { + const listeners = this.listeners.get(event) ?? new Set<(...args: Array) => void>(); + listeners.add(listener); + this.listeners.set(event, listeners); + return this; + } + + once(event: string, listener: (...args: Array) => void) { + const wrapped = (...args: Array) => { + this.off(event, wrapped); + listener(...args); + }; + return this.on(event, wrapped); + } + + off(event: string, listener: (...args: Array) => void) { + this.listeners.get(event)?.delete(listener); + return this; + } + + emit(event: string, ...args: Array) { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } + } + + class MockReadable extends MockEmitter { + write(data: string) { + this.emit("data", { + toString: () => data, + }); + } + } + + class MockChild extends MockEmitter { + readonly stdout = new MockReadable(); + readonly stderr = new MockReadable(); + readonly killCalls: Array = []; + + kill(signal?: string) { + this.killCalls.push(signal); + return true; + } + + emitExit(code: number | null, signal: string | null) { + this.emit("exit", code, signal); + } + } + + const spawnCalls: Array<{ + binaryPath: string; + args: string[]; + child: MockChild; + }> = []; + + const spawnMock = vi.fn((binaryPath: string, args: string[]) => { + const child = new MockChild(); + spawnCalls.push({ + binaryPath, + args: [...args], + child, + }); + return child; + }); + + return { + spawnCalls, + spawnMock, + }; +}); + +vi.mock("node:child_process", () => ({ + spawn: mockState.spawnMock, +})); + +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import { makeClaudeAdapterLive } from "./ClaudeAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const waitForAsyncEffects = () => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + +const claudeAdapterLayer = it.layer( + makeClaudeAdapterLive().pipe(Layer.provideMerge(NodeServices.layer)), +); + +claudeAdapterLayer("ClaudeAdapterLive lifecycle", (it) => { + it.effect("keeps the new turn interruptible after a stale exit from the previous session", () => + Effect.gen(function* () { + mockState.spawnCalls.length = 0; + mockState.spawnMock.mockClear(); + + const adapter = yield* ClaudeAdapter; + const threadId = asThreadId("thread-stale-exit"); + + yield* adapter.startSession({ + provider: "claude", + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + const firstChild = mockState.spawnCalls[0]?.child; + assert.ok(firstChild); + + yield* adapter.startSession({ + provider: "claude", + threadId, + runtimeMode: "full-access", + }); + assert.deepEqual(firstChild.killCalls, ["SIGTERM"]); + + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second turn", + attachments: [], + }); + const secondChild = mockState.spawnCalls[1]?.child; + assert.ok(secondChild); + + firstChild.emitExit(null, "SIGTERM"); + yield* waitForAsyncEffects(); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions[0]?.threadId, threadId); + assert.equal(sessions[0]?.status, "running"); + assert.equal(sessions[0]?.activeTurnId, secondTurn.turnId); + + yield* adapter.interruptTurn(threadId); + assert.deepEqual(secondChild.killCalls, ["SIGTERM"]); + assert.equal(firstTurn.turnId === secondTurn.turnId, false); + }), + ); + + it.effect("does not mark a replaced session resumable from a stale completed-process exit", () => + Effect.gen(function* () { + mockState.spawnCalls.length = 0; + mockState.spawnMock.mockClear(); + + const adapter = yield* ClaudeAdapter; + const threadId = asThreadId("thread-stale-complete"); + + yield* adapter.startSession({ + provider: "claude", + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + const firstChild = mockState.spawnCalls[0]?.child; + assert.ok(firstChild); + + firstChild.stdout.write(`${JSON.stringify({ type: "result", subtype: "success" })}\n`); + yield* waitForAsyncEffects(); + + yield* adapter.startSession({ + provider: "claude", + threadId, + runtimeMode: "full-access", + }); + + firstChild.emitExit(0, null); + + yield* adapter.sendTurn({ + threadId, + input: "second turn", + attachments: [], + }); + + const secondSpawn = mockState.spawnCalls[1]; + assert.ok(secondSpawn); + assert.equal(secondSpawn.binaryPath, "claude"); + assert.equal(secondSpawn.args.includes("--session-id"), true); + assert.equal(secondSpawn.args.includes("--resume"), false); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691..4d69cf611 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -4,7 +4,11 @@ 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 { + checkClaudeProviderStatus, + checkCodexProviderStatus, + parseAuthStatusFromOutput, +} from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -210,3 +214,49 @@ it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { assert.strictEqual(parsed.status, "warning"); assert.strictEqual(parsed.authStatus, "unknown"); }); + +it("parseAuthStatusFromOutput: Claude context rewrites generic verification failures", () => { + const parsed = parseAuthStatusFromOutput( + { + stdout: "", + stderr: "", + code: 2, + }, + { + providerLabel: "Claude Code CLI", + loginCommand: "claude auth login", + versionLabel: "Claude Code", + }, + ); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + assert.strictEqual( + parsed.message, + "Could not verify Claude Code CLI authentication status. Command exited with code 2.", + ); +}); + +it.effect("returns Claude-authenticated wording without Codex references", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claude"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Could not verify Claude Code CLI authentication status. Command exited with code 2.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") { + return { stdout: "", stderr: "", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), +); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 8d71af390..ab31133ab 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -35,6 +35,24 @@ export interface CommandResult { readonly code: number; } +interface AuthStatusMessageContext { + readonly providerLabel: string; + readonly loginCommand: string; + readonly versionLabel: string; +} + +const codexAuthStatusMessageContext: AuthStatusMessageContext = { + providerLabel: "Codex CLI", + loginCommand: "codex login", + versionLabel: "Codex", +}; + +const claudeAuthStatusMessageContext: AuthStatusMessageContext = { + providerLabel: "Claude Code CLI", + loginCommand: "claude auth login", + versionLabel: "Claude Code", +}; + function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -90,7 +108,10 @@ function extractAuthBoolean(value: unknown): boolean | undefined { return undefined; } -export function parseAuthStatusFromOutput(result: CommandResult): { +export function parseAuthStatusFromOutput( + result: CommandResult, + context: AuthStatusMessageContext = codexAuthStatusMessageContext, +): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; @@ -105,7 +126,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): { return { status: "warning", authStatus: "unknown", - message: "Codex CLI authentication status command is unavailable in this Codex version.", + message: `${context.providerLabel} authentication status command is unavailable in this ${context.versionLabel} version.`, }; } @@ -119,7 +140,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): { return { status: "error", authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", + message: `${context.providerLabel} is not authenticated. Run \`${context.loginCommand}\` and try again.`, }; } @@ -145,15 +166,14 @@ export function parseAuthStatusFromOutput(result: CommandResult): { return { status: "error", authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", + message: `${context.providerLabel} is not authenticated. Run \`${context.loginCommand}\` and try again.`, }; } if (parsedAuth.attemptedJsonParse) { return { status: "warning", authStatus: "unknown", - message: - "Could not verify Codex authentication status from JSON output (missing auth marker).", + message: `Could not verify ${context.providerLabel} authentication status from JSON output (missing auth marker).`, }; } if (result.code === 0) { @@ -165,8 +185,8 @@ export function parseAuthStatusFromOutput(result: CommandResult): { status: "warning", authStatus: "unknown", message: detail - ? `Could not verify Codex authentication status. ${detail}` - : "Could not verify Codex authentication status.", + ? `Could not verify ${context.providerLabel} authentication status. ${detail}` + : `Could not verify ${context.providerLabel} authentication status.`, }; } @@ -400,20 +420,14 @@ export const checkClaudeProviderStatus: Effect.Effect< }; } - const parsed = parseAuthStatusFromOutput(authProbe.success.value); + const parsed = parseAuthStatusFromOutput(authProbe.success.value, claudeAuthStatusMessageContext); return { provider: CLAUDE_PROVIDER, status: parsed.status, available: true, authStatus: parsed.authStatus, checkedAt, - ...(parsed.message - ? { - message: parsed.message - .replaceAll("Codex CLI", "Claude Code CLI") - .replaceAll("codex login", "claude auth login"), - } - : {}), + ...(parsed.message ? { message: parsed.message } : {}), } satisfies ServerProviderStatus; });