| author | |
| committer | |
| log | 802cd127d17d77ba9d598675574b9d01480a94b0 |
| tree | effa984e82f53d5287e17be4b7c8e7b856f060f0 |
| parent | 931c56b2d0bf873e8923aa51a8e95be929999a5e |
- 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 passthrough3 files changed, 558 insertions(+), 0 deletions(-)
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts created+269| ... | @@ -0,0 +1,269 @@ | ||
| 1 | import type { | ||
| 2 | ProviderApprovalDecision, | ||
| 3 | ProviderRuntimeEvent, | ||
| 4 | ProviderSendTurnInput, | ||
| 5 | ProviderSession, | ||
| 6 | ProviderSessionStartInput, | ||
| 7 | ProviderTurnStartResult, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | ApprovalRequestId, | ||
| 11 | EventId, | ||
| 12 | ProviderSessionId, | ||
| 13 | ProviderThreadId, | ||
| 14 | ProviderTurnId, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import { afterAll, assert, it, vi } from "@effect/vitest"; | ||
| 17 | import { assertFailure } from "@effect/vitest/utils"; | ||
| 18 | |||
| 19 | import { Effect, Fiber, Queue, Stream } from "effect"; | ||
| 20 | |||
| 21 | import { ProviderAdapterValidationError } from "../Errors.ts"; | ||
| 22 | import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; | ||
| 23 | import { type ClaudeCodeRuntime, makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; | ||
| 24 | |||
| 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | ||
| 26 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | ||
| 27 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | ||
| 28 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | ||
| 29 | |||
| 30 | class 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 | |||
| 136 | const validationRuntime = new FakeClaudeRuntime(); | ||
| 137 | const validationLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: validationRuntime })); | ||
| 138 | |||
| 139 | validationLayer("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 | |||
| 162 | const sessionErrorRuntime = new FakeClaudeRuntime(); | ||
| 163 | sessionErrorRuntime.sendTurnImpl.mockImplementation(async () => { | ||
| 164 | throw new Error("Unknown session: claude-sess-missing"); | ||
| 165 | }); | ||
| 166 | const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime })); | ||
| 167 | |||
| 168 | sessionErrorLayer("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 | |||
| 196 | const lifecycleRuntime = new FakeClaudeRuntime(); | ||
| 197 | const lifecycleLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: lifecycleRuntime })); | ||
| 198 | |||
| 199 | lifecycleLayer("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 | |||
| 265 | afterAll(() => { | ||
| 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 | */ | ||
| 9 | import type { | ||
| 10 | ApprovalRequestId, | ||
| 11 | ProviderApprovalDecision, | ||
| 12 | ProviderRuntimeEvent, | ||
| 13 | ProviderSendTurnInput, | ||
| 14 | ProviderSession, | ||
| 15 | ProviderSessionId, | ||
| 16 | ProviderSessionStartInput, | ||
| 17 | ProviderTurnId, | ||
| 18 | ProviderTurnStartResult, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { Effect, Layer, Stream } from "effect"; | ||
| 21 | |||
| 22 | import { | ||
| 23 | ProviderAdapterProcessError, | ||
| 24 | ProviderAdapterRequestError, | ||
| 25 | ProviderAdapterSessionClosedError, | ||
| 26 | ProviderAdapterSessionNotFoundError, | ||
| 27 | ProviderAdapterValidationError, | ||
| 28 | type ProviderAdapterError, | ||
| 29 | } from "../Errors.ts"; | ||
| 30 | import { type ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; | ||
| 31 | import { | ||
| 32 | ClaudeCodeAdapter, | ||
| 33 | type ClaudeCodeAdapterShape, | ||
| 34 | } from "../Services/ClaudeCodeAdapter.ts"; | ||
| 35 | |||
| 36 | const PROVIDER = "claudeCode" as const; | ||
| 37 | |||
| 38 | export 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 | |||
| 59 | export interface ClaudeCodeAdapterLiveOptions { | ||
| 60 | readonly runtime?: ClaudeCodeRuntime; | ||
| 61 | readonly makeRuntime?: () => ClaudeCodeRuntime; | ||
| 62 | } | ||
| 63 | |||
| 64 | function 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 | |||
| 71 | function 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 | |||
| 93 | function 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 | |||
| 110 | function 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 | |||
| 130 | const 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 | |||
| 252 | export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); | ||
| 253 | |||
| 254 | export 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 | */ | ||
| 13 | import { ServiceMap } from "effect"; | ||
| 14 | |||
| 15 | import type { ProviderAdapterError } from "../Errors.ts"; | ||
| 16 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | ||
| 17 | |||
| 18 | /** | ||
| 19 | * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter. | ||
| 20 | */ | ||
| 21 | export interface ClaudeCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | ||
| 22 | readonly provider: "claudeCode"; | ||
| 23 | } | ||
| 24 | |||
| 25 | /** | ||
| 26 | * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations. | ||
| 27 | */ | ||
| 28 | export class ClaudeCodeAdapter extends ServiceMap.Service< | ||
| 29 | ClaudeCodeAdapter, | ||
| 30 | ClaudeCodeAdapterShape | ||
| 31 | >()("t3/provider/Services/ClaudeCodeAdapter") {} | ||
| 32 | |||