authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 01:51:08-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log802cd127d17d77ba9d598675574b9d01480a94b0
treeeffa984e82f53d5287e17be4b7c8e7b856f060f0
parent931c56b2d0bf873e8923aa51a8e95be929999a5e

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

3 files changed, 558 insertions(+), 0 deletions(-)

apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts created+269
...@@ -0,0 +1,269 @@
1import type {
2 ProviderApprovalDecision,
3 ProviderRuntimeEvent,
4 ProviderSendTurnInput,
5 ProviderSession,
6 ProviderSessionStartInput,
7 ProviderTurnStartResult,
8} from "@t3tools/contracts";
9import {
10 ApprovalRequestId,
11 EventId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15} from "@t3tools/contracts";
16import { afterAll, assert, it, vi } from "@effect/vitest";
17import { assertFailure } from "@effect/vitest/utils";
18
19import { Effect, Fiber, Queue, Stream } from "effect";
20
21import { ProviderAdapterValidationError } from "../Errors.ts";
22import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";
23import { type ClaudeCodeRuntime, makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts";
24
25const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
26const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
27const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
28const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
29
30class FakeClaudeRuntime implements ClaudeCodeRuntime {
31 private readonly runtimeEventQueue = Effect.runSync(Queue.unbounded<ProviderRuntimeEvent>());
32 readonly streamEvents = Stream.fromQueue(this.runtimeEventQueue);
33
34 public startSessionImpl = vi.fn(
35 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
36 const now = new Date().toISOString();
37 return {
38 sessionId: asSessionId("claude-sess-1"),
39 provider: "claudeCode",
40 status: "ready",
41 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
42 resumeCursor: input.resumeCursor ?? { opaque: "claude-cursor-1" },
43 cwd: input.cwd,
44 model: input.model,
45 createdAt: now,
46 updatedAt: now,
47 };
48 },
49 );
50
51 public sendTurnImpl = vi.fn(
52 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
53 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
54 turnId: asTurnId("claude-turn-1"),
55 }),
56 );
57
58 public interruptTurnImpl = vi.fn(
59 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,
60 );
61
62 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
63 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
64 turns: [],
65 }));
66
67 public rollbackThreadImpl = vi.fn(
68 async (_sessionId: ProviderSessionId, _numTurns: number) => ({
69 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
70 turns: [],
71 }),
72 );
73
74 public respondToRequestImpl = vi.fn(
75 async (
76 _sessionId: ProviderSessionId,
77 _requestId: ApprovalRequestId,
78 _decision: ProviderApprovalDecision,
79 ): Promise<void> => undefined,
80 );
81
82 public stopSessionImpl = vi.fn((_sessionId: ProviderSessionId) => undefined);
83 public listSessionsImpl = vi.fn((): ReadonlyArray<ProviderSession> => []);
84 public hasSessionImpl = vi.fn((_sessionId: ProviderSessionId): boolean => false);
85 public stopAllImpl = vi.fn(() => undefined);
86
87 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
88 return this.startSessionImpl(input);
89 }
90
91 async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
92 return this.sendTurnImpl(input);
93 }
94
95 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
96 return this.interruptTurnImpl(sessionId, turnId);
97 }
98
99 async readThread(sessionId: ProviderSessionId) {
100 return this.readThreadImpl(sessionId);
101 }
102
103 async rollbackThread(sessionId: ProviderSessionId, numTurns: number) {
104 return this.rollbackThreadImpl(sessionId, numTurns);
105 }
106
107 async respondToRequest(
108 sessionId: ProviderSessionId,
109 requestId: ApprovalRequestId,
110 decision: ProviderApprovalDecision,
111 ): Promise<void> {
112 return this.respondToRequestImpl(sessionId, requestId, decision);
113 }
114
115 stopSession(sessionId: ProviderSessionId): void {
116 this.stopSessionImpl(sessionId);
117 }
118
119 listSessions(): ReadonlyArray<ProviderSession> {
120 return this.listSessionsImpl();
121 }
122
123 hasSession(sessionId: ProviderSessionId): boolean {
124 return this.hasSessionImpl(sessionId);
125 }
126
127 stopAll(): void {
128 this.stopAllImpl();
129 }
130
131 emitRuntimeEvent(event: ProviderRuntimeEvent): void {
132 Queue.offerAllUnsafe(this.runtimeEventQueue, [event]);
133 }
134}
135
136const validationRuntime = new FakeClaudeRuntime();
137const validationLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: validationRuntime }));
138
139validationLayer("ClaudeCodeAdapterLive validation", (it) => {
140 it.effect("returns validation error for non-claudeCode provider on startSession", () =>
141 Effect.gen(function* () {
142 const adapter = yield* ClaudeCodeAdapter;
143 const result = yield* adapter
144 .startSession({
145 provider: "codex",
146 })
147 .pipe(Effect.result);
148
149 assertFailure(
150 result,
151 new ProviderAdapterValidationError({
152 provider: "claudeCode",
153 operation: "startSession",
154 issue: "Expected provider 'claudeCode' but received 'codex'.",
155 }),
156 );
157 assert.equal(validationRuntime.startSessionImpl.mock.calls.length, 0);
158 }),
159 );
160});
161
162const sessionErrorRuntime = new FakeClaudeRuntime();
163sessionErrorRuntime.sendTurnImpl.mockImplementation(async () => {
164 throw new Error("Unknown session: claude-sess-missing");
165});
166const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime }));
167
168sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => {
169 it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () =>
170 Effect.gen(function* () {
171 const adapter = yield* ClaudeCodeAdapter;
172 const result = yield* adapter
173 .sendTurn({
174 sessionId: asSessionId("claude-sess-missing"),
175 input: "hello",
176 attachments: [],
177 })
178 .pipe(Effect.result);
179
180 assert.equal(result._tag, "Failure");
181 if (result._tag !== "Failure") {
182 return;
183 }
184
185 assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError");
186 if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") {
187 return;
188 }
189 assert.equal(result.failure.provider, "claudeCode");
190 assert.equal(result.failure.sessionId, "claude-sess-missing");
191 assert.instanceOf(result.failure.cause, Error);
192 }),
193 );
194});
195
196const lifecycleRuntime = new FakeClaudeRuntime();
197const lifecycleLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: lifecycleRuntime }));
198
199lifecycleLayer("ClaudeCodeAdapterLive lifecycle", (it) => {
200 it.effect("forwards providerOptions and opaque resumeCursor to runtime startSession", () =>
201 Effect.gen(function* () {
202 const adapter = yield* ClaudeCodeAdapter;
203 const resumeCursor = {
204 threadId: "provider-thread-opaque",
205 sessionAt: "message:42",
206 };
207 const started = yield* adapter.startSession({
208 provider: "claudeCode",
209 cwd: "/tmp/claude-workspace",
210 model: "claude-sonnet-4",
211 resumeCursor,
212 providerOptions: {
213 claudeCode: {
214 binaryPath: "/usr/local/bin/claude",
215 permissionMode: "acceptEdits",
216 maxThinkingTokens: 4_096,
217 },
218 },
219 });
220
221 assert.equal(started.provider, "claudeCode");
222 assert.deepEqual(started.resumeCursor, resumeCursor);
223 assert.deepEqual(lifecycleRuntime.startSessionImpl.mock.calls[0]?.[0], {
224 provider: "claudeCode",
225 cwd: "/tmp/claude-workspace",
226 model: "claude-sonnet-4",
227 resumeCursor,
228 providerOptions: {
229 claudeCode: {
230 binaryPath: "/usr/local/bin/claude",
231 permissionMode: "acceptEdits",
232 maxThinkingTokens: 4_096,
233 },
234 },
235 });
236 }),
237 );
238
239 it.effect("passes through canonical runtime events without remapping", () =>
240 Effect.gen(function* () {
241 const adapter = yield* ClaudeCodeAdapter;
242 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
243 const runtimeEvent: ProviderRuntimeEvent = {
244 type: "approval.requested",
245 eventId: asEventId("evt-claude-approval"),
246 provider: "claudeCode",
247 sessionId: asSessionId("claude-sess-1"),
248 createdAt: new Date().toISOString(),
249 requestId: asRequestId("approval-claude-1"),
250 requestKind: "command",
251 };
252
253 lifecycleRuntime.emitRuntimeEvent(runtimeEvent);
254 const firstEvent = yield* Fiber.join(firstEventFiber);
255
256 assert.equal(firstEvent._tag, "Some");
257 if (firstEvent._tag !== "Some") {
258 return;
259 }
260 assert.deepEqual(firstEvent.value, runtimeEvent);
261 }),
262 );
263});
264
265afterAll(() => {
266 assert.equal(validationRuntime.stopAllImpl.mock.calls.length, 1);
267 assert.equal(sessionErrorRuntime.stopAllImpl.mock.calls.length, 1);
268 assert.equal(lifecycleRuntime.stopAllImpl.mock.calls.length, 1);
269});
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts created+257
...@@ -0,0 +1,257 @@
1/**
2 * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter.
3 *
4 * Wraps a Claude runtime bridge behind the `ClaudeCodeAdapter` service contract
5 * and maps runtime failures into the shared `ProviderAdapterError` algebra.
6 *
7 * @module ClaudeCodeAdapterLive
8 */
9import type {
10 ApprovalRequestId,
11 ProviderApprovalDecision,
12 ProviderRuntimeEvent,
13 ProviderSendTurnInput,
14 ProviderSession,
15 ProviderSessionId,
16 ProviderSessionStartInput,
17 ProviderTurnId,
18 ProviderTurnStartResult,
19} from "@t3tools/contracts";
20import { Effect, Layer, Stream } from "effect";
21
22import {
23 ProviderAdapterProcessError,
24 ProviderAdapterRequestError,
25 ProviderAdapterSessionClosedError,
26 ProviderAdapterSessionNotFoundError,
27 ProviderAdapterValidationError,
28 type ProviderAdapterError,
29} from "../Errors.ts";
30import { type ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts";
31import {
32 ClaudeCodeAdapter,
33 type ClaudeCodeAdapterShape,
34} from "../Services/ClaudeCodeAdapter.ts";
35
36const PROVIDER = "claudeCode" as const;
37
38export interface ClaudeCodeRuntime {
39 readonly startSession: (input: ProviderSessionStartInput) => Promise<ProviderSession>;
40 readonly sendTurn: (input: ProviderSendTurnInput) => Promise<ProviderTurnStartResult>;
41 readonly interruptTurn: (sessionId: ProviderSessionId, turnId?: ProviderTurnId) => Promise<void>;
42 readonly readThread: (sessionId: ProviderSessionId) => Promise<ProviderThreadSnapshot>;
43 readonly rollbackThread: (
44 sessionId: ProviderSessionId,
45 numTurns: number,
46 ) => Promise<ProviderThreadSnapshot>;
47 readonly respondToRequest: (
48 sessionId: ProviderSessionId,
49 requestId: ApprovalRequestId,
50 decision: ProviderApprovalDecision,
51 ) => Promise<void>;
52 readonly stopSession: (sessionId: ProviderSessionId) => void;
53 readonly listSessions: () => ReadonlyArray<ProviderSession>;
54 readonly hasSession: (sessionId: ProviderSessionId) => boolean;
55 readonly stopAll: () => void;
56 readonly streamEvents: Stream.Stream<ProviderRuntimeEvent>;
57}
58
59export interface ClaudeCodeAdapterLiveOptions {
60 readonly runtime?: ClaudeCodeRuntime;
61 readonly makeRuntime?: () => ClaudeCodeRuntime;
62}
63
64function toMessage(cause: unknown, fallback: string): string {
65 if (cause instanceof Error && cause.message.length > 0) {
66 return cause.message;
67 }
68 return fallback;
69}
70
71function toSessionError(
72 sessionId: ProviderSessionId,
73 cause: unknown,
74): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
75 const normalized = toMessage(cause, "").toLowerCase();
76 if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) {
77 return new ProviderAdapterSessionNotFoundError({
78 provider: PROVIDER,
79 sessionId,
80 cause,
81 });
82 }
83 if (normalized.includes("session is closed")) {
84 return new ProviderAdapterSessionClosedError({
85 provider: PROVIDER,
86 sessionId,
87 cause,
88 });
89 }
90 return undefined;
91}
92
93function toRequestError(
94 sessionId: ProviderSessionId,
95 method: string,
96 cause: unknown,
97): ProviderAdapterError {
98 const sessionError = toSessionError(sessionId, cause);
99 if (sessionError) {
100 return sessionError;
101 }
102 return new ProviderAdapterRequestError({
103 provider: PROVIDER,
104 method,
105 detail: toMessage(cause, `${method} failed`),
106 cause,
107 });
108}
109
110function makeUnavailableRuntime(): ClaudeCodeRuntime {
111 const unavailable = async (): Promise<never> => {
112 throw new Error("Claude Code runtime is not configured.");
113 };
114
115 return {
116 startSession: unavailable,
117 sendTurn: unavailable,
118 interruptTurn: unavailable,
119 readThread: unavailable,
120 rollbackThread: unavailable,
121 respondToRequest: unavailable,
122 stopSession: () => {},
123 listSessions: () => [],
124 hasSession: () => false,
125 stopAll: () => {},
126 streamEvents: Stream.empty,
127 };
128}
129
130const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
131 Effect.gen(function* () {
132 const runtime = yield* Effect.acquireRelease(
133 Effect.sync(() => {
134 if (options?.runtime) {
135 return options.runtime;
136 }
137 if (options?.makeRuntime) {
138 return options.makeRuntime();
139 }
140 return makeUnavailableRuntime();
141 }),
142 (runtime) =>
143 Effect.sync(() => {
144 try {
145 runtime.stopAll();
146 } catch {
147 // Finalizers should never fail and block shutdown.
148 }
149 }),
150 );
151
152 const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => {
153 if (input.provider !== undefined && input.provider !== PROVIDER) {
154 return Effect.fail(
155 new ProviderAdapterValidationError({
156 provider: PROVIDER,
157 operation: "startSession",
158 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
159 }),
160 );
161 }
162
163 return Effect.tryPromise({
164 try: () => runtime.startSession(input),
165 catch: (cause) =>
166 new ProviderAdapterProcessError({
167 provider: PROVIDER,
168 sessionId: "pending",
169 detail: toMessage(cause, "Failed to start Claude Code adapter session."),
170 cause,
171 }),
172 });
173 };
174
175 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
176 Effect.tryPromise({
177 try: () => runtime.sendTurn(input),
178 catch: (cause) => toRequestError(input.sessionId, "turn/start", cause),
179 });
180
181 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, turnId) =>
182 Effect.tryPromise({
183 try: () => runtime.interruptTurn(sessionId, turnId),
184 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),
185 });
186
187 const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) =>
188 Effect.tryPromise({
189 try: () => runtime.readThread(sessionId),
190 catch: (cause) => toRequestError(sessionId, "thread/read", cause),
191 });
192
193 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => {
194 if (!Number.isInteger(numTurns) || numTurns < 1) {
195 return Effect.fail(
196 new ProviderAdapterValidationError({
197 provider: PROVIDER,
198 operation: "rollbackThread",
199 issue: "numTurns must be an integer >= 1.",
200 }),
201 );
202 }
203
204 return Effect.tryPromise({
205 try: () => runtime.rollbackThread(sessionId, numTurns),
206 catch: (cause) => toRequestError(sessionId, "thread/rollback", cause),
207 });
208 };
209
210 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
211 sessionId,
212 requestId,
213 decision,
214 ) =>
215 Effect.tryPromise({
216 try: () => runtime.respondToRequest(sessionId, requestId, decision),
217 catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause),
218 });
219
220 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) =>
221 Effect.sync(() => {
222 runtime.stopSession(sessionId);
223 });
224
225 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>
226 Effect.sync(() => runtime.listSessions());
227
228 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) =>
229 Effect.sync(() => runtime.hasSession(sessionId));
230
231 const stopAll: ClaudeCodeAdapterShape["stopAll"] = () =>
232 Effect.sync(() => {
233 runtime.stopAll();
234 });
235
236 return {
237 provider: PROVIDER,
238 startSession,
239 sendTurn,
240 interruptTurn,
241 readThread,
242 rollbackThread,
243 respondToRequest,
244 stopSession,
245 listSessions,
246 hasSession,
247 stopAll,
248 streamEvents: runtime.streamEvents,
249 } satisfies ClaudeCodeAdapterShape;
250 });
251
252export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter());
253
254export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) {
255 return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options));
256}
257
apps/server/src/provider/Services/ClaudeCodeAdapter.ts created+32
...@@ -0,0 +1,32 @@
1/**
2 * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract.
3 *
4 * This service owns Claude runtime/session semantics and emits canonical
5 * provider runtime events. It does not perform cross-provider routing, shared
6 * event fan-out, or checkpoint orchestration.
7 *
8 * Uses Effect `ServiceMap.Service` for dependency injection and returns the
9 * shared provider-adapter error channel with `provider: "claudeCode"` context.
10 *
11 * @module ClaudeCodeAdapter
12 */
13import { ServiceMap } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
17
18/**
19 * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter.
20 */
21export interface ClaudeCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "claudeCode";
23}
24
25/**
26 * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations.
27 */
28export class ClaudeCodeAdapter extends ServiceMap.Service<
29 ClaudeCodeAdapter,
30 ClaudeCodeAdapterShape
31>()("t3/provider/Services/ClaudeCodeAdapter") {}
32