From 802cd127d17d77ba9d598675574b9d01480a94b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 01:51:08 -0800 Subject: [PATCH] Add Claude Code adapter live layer with error mapping tests - introduce `ClaudeCodeAdapter` service and live layer wiring - map runtime/session/request failures into provider adapter error types - add coverage for validation, session-not-found mapping, lifecycle forwarding, and event passthrough --- .../provider/Layers/ClaudeCodeAdapter.test.ts | 269 ++++++++++++++++++ .../src/provider/Layers/ClaudeCodeAdapter.ts | 257 +++++++++++++++++ .../provider/Services/ClaudeCodeAdapter.ts | 32 +++ 3 files changed, 558 insertions(+) create mode 100644 apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeCodeAdapter.ts create mode 100644 apps/server/src/provider/Services/ClaudeCodeAdapter.ts diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..95a9f8fdab01fc96400880deecaa79f1d29b8711 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -0,0 +1,269 @@ +import type { + ProviderApprovalDecision, + ProviderRuntimeEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { + ApprovalRequestId, + EventId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, +} from "@t3tools/contracts"; +import { afterAll, assert, it, vi } from "@effect/vitest"; +import { assertFailure } from "@effect/vitest/utils"; + +import { Effect, Fiber, Queue, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { type ClaudeCodeRuntime, makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; + +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); + +class FakeClaudeRuntime implements ClaudeCodeRuntime { + private readonly runtimeEventQueue = Effect.runSync(Queue.unbounded()); + readonly streamEvents = Stream.fromQueue(this.runtimeEventQueue); + + public startSessionImpl = vi.fn( + async (input: ProviderSessionStartInput): Promise => { + const now = new Date().toISOString(); + return { + sessionId: asSessionId("claude-sess-1"), + provider: "claudeCode", + status: "ready", + threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), + resumeCursor: input.resumeCursor ?? { opaque: "claude-cursor-1" }, + cwd: input.cwd, + model: input.model, + createdAt: now, + updatedAt: now, + }; + }, + ); + + public sendTurnImpl = vi.fn( + async (_input: ProviderSendTurnInput): Promise => ({ + threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), + turnId: asTurnId("claude-turn-1"), + }), + ); + + public interruptTurnImpl = vi.fn( + async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise => undefined, + ); + + public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ + threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), + turns: [], + })); + + public rollbackThreadImpl = vi.fn( + async (_sessionId: ProviderSessionId, _numTurns: number) => ({ + threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), + turns: [], + }), + ); + + public respondToRequestImpl = vi.fn( + async ( + _sessionId: ProviderSessionId, + _requestId: ApprovalRequestId, + _decision: ProviderApprovalDecision, + ): Promise => undefined, + ); + + public stopSessionImpl = vi.fn((_sessionId: ProviderSessionId) => undefined); + public listSessionsImpl = vi.fn((): ReadonlyArray => []); + public hasSessionImpl = vi.fn((_sessionId: ProviderSessionId): boolean => false); + public stopAllImpl = vi.fn(() => undefined); + + async startSession(input: ProviderSessionStartInput): Promise { + return this.startSessionImpl(input); + } + + async sendTurn(input: ProviderSendTurnInput): Promise { + return this.sendTurnImpl(input); + } + + async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { + return this.interruptTurnImpl(sessionId, turnId); + } + + async readThread(sessionId: ProviderSessionId) { + return this.readThreadImpl(sessionId); + } + + async rollbackThread(sessionId: ProviderSessionId, numTurns: number) { + return this.rollbackThreadImpl(sessionId, numTurns); + } + + async respondToRequest( + sessionId: ProviderSessionId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ): Promise { + return this.respondToRequestImpl(sessionId, requestId, decision); + } + + stopSession(sessionId: ProviderSessionId): void { + this.stopSessionImpl(sessionId); + } + + listSessions(): ReadonlyArray { + return this.listSessionsImpl(); + } + + hasSession(sessionId: ProviderSessionId): boolean { + return this.hasSessionImpl(sessionId); + } + + stopAll(): void { + this.stopAllImpl(); + } + + emitRuntimeEvent(event: ProviderRuntimeEvent): void { + Queue.offerAllUnsafe(this.runtimeEventQueue, [event]); + } +} + +const validationRuntime = new FakeClaudeRuntime(); +const validationLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: validationRuntime })); + +validationLayer("ClaudeCodeAdapterLive validation", (it) => { + it.effect("returns validation error for non-claudeCode provider on startSession", () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const result = yield* adapter + .startSession({ + provider: "codex", + }) + .pipe(Effect.result); + + assertFailure( + result, + new ProviderAdapterValidationError({ + provider: "claudeCode", + operation: "startSession", + issue: "Expected provider 'claudeCode' but received 'codex'.", + }), + ); + assert.equal(validationRuntime.startSessionImpl.mock.calls.length, 0); + }), + ); +}); + +const sessionErrorRuntime = new FakeClaudeRuntime(); +sessionErrorRuntime.sendTurnImpl.mockImplementation(async () => { + throw new Error("Unknown session: claude-sess-missing"); +}); +const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime })); + +sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { + it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const result = yield* adapter + .sendTurn({ + sessionId: asSessionId("claude-sess-missing"), + input: "hello", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + + assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { + return; + } + assert.equal(result.failure.provider, "claudeCode"); + assert.equal(result.failure.sessionId, "claude-sess-missing"); + assert.instanceOf(result.failure.cause, Error); + }), + ); +}); + +const lifecycleRuntime = new FakeClaudeRuntime(); +const lifecycleLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: lifecycleRuntime })); + +lifecycleLayer("ClaudeCodeAdapterLive lifecycle", (it) => { + it.effect("forwards providerOptions and opaque resumeCursor to runtime startSession", () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const resumeCursor = { + threadId: "provider-thread-opaque", + sessionAt: "message:42", + }; + const started = yield* adapter.startSession({ + provider: "claudeCode", + cwd: "/tmp/claude-workspace", + model: "claude-sonnet-4", + resumeCursor, + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "acceptEdits", + maxThinkingTokens: 4_096, + }, + }, + }); + + assert.equal(started.provider, "claudeCode"); + assert.deepEqual(started.resumeCursor, resumeCursor); + assert.deepEqual(lifecycleRuntime.startSessionImpl.mock.calls[0]?.[0], { + provider: "claudeCode", + cwd: "/tmp/claude-workspace", + model: "claude-sonnet-4", + resumeCursor, + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "acceptEdits", + maxThinkingTokens: 4_096, + }, + }, + }); + }), + ); + + it.effect("passes through canonical runtime events without remapping", () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + const runtimeEvent: ProviderRuntimeEvent = { + type: "approval.requested", + eventId: asEventId("evt-claude-approval"), + provider: "claudeCode", + sessionId: asSessionId("claude-sess-1"), + createdAt: new Date().toISOString(), + requestId: asRequestId("approval-claude-1"), + requestKind: "command", + }; + + lifecycleRuntime.emitRuntimeEvent(runtimeEvent); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.deepEqual(firstEvent.value, runtimeEvent); + }), + ); +}); + +afterAll(() => { + assert.equal(validationRuntime.stopAllImpl.mock.calls.length, 1); + assert.equal(sessionErrorRuntime.stopAllImpl.mock.calls.length, 1); + assert.equal(lifecycleRuntime.stopAllImpl.mock.calls.length, 1); +}); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..2472695f6ef28170b2b1bf0af2369b63547d12d0 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,257 @@ +/** + * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * + * Wraps a Claude runtime bridge behind the `ClaudeCodeAdapter` service contract + * and maps runtime failures into the shared `ProviderAdapterError` algebra. + * + * @module ClaudeCodeAdapterLive + */ +import type { + ApprovalRequestId, + ProviderApprovalDecision, + ProviderRuntimeEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionId, + ProviderSessionStartInput, + ProviderTurnId, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { Effect, Layer, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { type ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { + ClaudeCodeAdapter, + type ClaudeCodeAdapterShape, +} from "../Services/ClaudeCodeAdapter.ts"; + +const PROVIDER = "claudeCode" as const; + +export interface ClaudeCodeRuntime { + readonly startSession: (input: ProviderSessionStartInput) => Promise; + readonly sendTurn: (input: ProviderSendTurnInput) => Promise; + readonly interruptTurn: (sessionId: ProviderSessionId, turnId?: ProviderTurnId) => Promise; + readonly readThread: (sessionId: ProviderSessionId) => Promise; + readonly rollbackThread: ( + sessionId: ProviderSessionId, + numTurns: number, + ) => Promise; + readonly respondToRequest: ( + sessionId: ProviderSessionId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + readonly stopSession: (sessionId: ProviderSessionId) => void; + readonly listSessions: () => ReadonlyArray; + readonly hasSession: (sessionId: ProviderSessionId) => boolean; + readonly stopAll: () => void; + readonly streamEvents: Stream.Stream; +} + +export interface ClaudeCodeAdapterLiveOptions { + readonly runtime?: ClaudeCodeRuntime; + readonly makeRuntime?: () => ClaudeCodeRuntime; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toSessionError( + sessionId: ProviderSessionId, + 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, + sessionId, + cause, + }); + } + if (normalized.includes("session is closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + sessionId, + cause, + }); + } + return undefined; +} + +function toRequestError( + sessionId: ProviderSessionId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(sessionId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function makeUnavailableRuntime(): ClaudeCodeRuntime { + const unavailable = async (): Promise => { + throw new Error("Claude Code runtime is not configured."); + }; + + return { + startSession: unavailable, + sendTurn: unavailable, + interruptTurn: unavailable, + readThread: unavailable, + rollbackThread: unavailable, + respondToRequest: unavailable, + stopSession: () => {}, + listSessions: () => [], + hasSession: () => false, + stopAll: () => {}, + streamEvents: Stream.empty, + }; +} + +const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) => + Effect.gen(function* () { + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => { + if (options?.runtime) { + return options.runtime; + } + if (options?.makeRuntime) { + return options.makeRuntime(); + } + return makeUnavailableRuntime(); + }), + (runtime) => + Effect.sync(() => { + try { + runtime.stopAll(); + } catch { + // Finalizers should never fail and block shutdown. + } + }), + ); + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }), + ); + } + + return Effect.tryPromise({ + try: () => runtime.startSession(input), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + sessionId: "pending", + detail: toMessage(cause, "Failed to start Claude Code adapter session."), + cause, + }), + }); + }; + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.tryPromise({ + try: () => runtime.sendTurn(input), + catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, turnId) => + Effect.tryPromise({ + try: () => runtime.interruptTurn(sessionId, turnId), + catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), + }); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) => + Effect.tryPromise({ + try: () => runtime.readThread(sessionId), + catch: (cause) => toRequestError(sessionId, "thread/read", cause), + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }), + ); + } + + return Effect.tryPromise({ + try: () => runtime.rollbackThread(sessionId, numTurns), + catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), + }); + }; + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + sessionId, + requestId, + decision, + ) => + Effect.tryPromise({ + try: () => runtime.respondToRequest(sessionId, requestId, decision), + catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), + }); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) => + Effect.sync(() => { + runtime.stopSession(sessionId); + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => runtime.listSessions()); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) => + Effect.sync(() => runtime.hasSession(sessionId)); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.sync(() => { + runtime.stopAll(); + }); + + return { + provider: PROVIDER, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: runtime.streamEvents, + } satisfies ClaudeCodeAdapterShape; + }); + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} + diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..80fb8771d801312f8223041c6c6ba891ee90afaf --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts @@ -0,0 +1,32 @@ +/** + * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract. + * + * This service owns Claude runtime/session semantics and emits canonical + * provider runtime events. It does not perform cross-provider routing, shared + * event fan-out, or checkpoint orchestration. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "claudeCode"` context. + * + * @module ClaudeCodeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter. + */ +export interface ClaudeCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "claudeCode"; +} + +/** + * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations. + */ +export class ClaudeCodeAdapter extends ServiceMap.Service< + ClaudeCodeAdapter, + ClaudeCodeAdapterShape +>()("t3/provider/Services/ClaudeCodeAdapter") {} + -- 2.54.0