| @@ -1,186 +1,79 @@ |
| 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"; |
| 1 | +import { EventId, ProviderSessionId, ProviderThreadId, ProviderTurnId } from "@t3tools/contracts"; |
| 2 | +import { assert, describe, it } from "@effect/vitest"; |
| 3 | +import { Effect, Layer, Stream } from "effect"; |
| 20 | 4 | |
| 21 | | −import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 22 | | −import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; |
| 23 | | −import { type ClaudeCodeRuntime, makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; |
| 5 | +import { |
| 6 | + ProviderAdapterProcessError, |
| 7 | + ProviderAdapterRequestError, |
| 8 | + ProviderAdapterValidationError, |
| 9 | +} from "../Errors.ts"; |
| 10 | +import { |
| 11 | + ClaudeCodeAdapter, |
| 12 | + type ClaudeCodeAdapterShape, |
| 13 | +} from "../Services/ClaudeCodeAdapter.ts"; |
| 14 | +import { makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; |
| 24 | 15 | |
| 25 | 16 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 26 | 17 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 27 | 18 | 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 | | − (input: ProviderSessionStartInput): Effect.Effect<ProviderSession> => { |
| 36 | | − const now = new Date().toISOString(); |
| 37 | | − return Effect.succeed({ |
| 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 | | − (_input: ProviderSendTurnInput): Effect.Effect<ProviderTurnStartResult> => |
| 53 | | − Effect.succeed({ |
| 54 | | − threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), |
| 55 | | − turnId: asTurnId("claude-turn-1"), |
| 56 | | − }), |
| 57 | | − ); |
| 58 | | − |
| 59 | | − public interruptTurnImpl = vi.fn( |
| 60 | | − (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Effect.Effect<void> => Effect.void, |
| 61 | | − ); |
| 62 | | − |
| 63 | | − public readThreadImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<{ |
| 64 | | − threadId: ProviderThreadId; |
| 65 | | − turns: never[]; |
| 66 | | − }> => |
| 67 | | − Effect.succeed({ |
| 68 | | − threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), |
| 69 | | − turns: [], |
| 70 | | − })); |
| 71 | | − |
| 72 | | − public rollbackThreadImpl = vi.fn( |
| 73 | | − (_sessionId: ProviderSessionId, _numTurns: number): Effect.Effect<{ |
| 74 | | − threadId: ProviderThreadId; |
| 75 | | − turns: never[]; |
| 76 | | − }> => |
| 77 | | − Effect.succeed({ |
| 78 | | − threadId: ProviderThreadId.makeUnsafe("claude-thread-1"), |
| 79 | | − turns: [], |
| 80 | | − }), |
| 81 | | − ); |
| 82 | | − |
| 83 | | − public respondToRequestImpl = vi.fn( |
| 84 | | − ( |
| 85 | | − _sessionId: ProviderSessionId, |
| 86 | | − _requestId: ApprovalRequestId, |
| 87 | | − _decision: ProviderApprovalDecision, |
| 88 | | − ): Effect.Effect<void> => Effect.void, |
| 89 | | − ); |
| 90 | | − |
| 91 | | − public stopSessionImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<void> => Effect.void); |
| 92 | | − public listSessionsImpl = vi.fn((): Effect.Effect<ReadonlyArray<ProviderSession>> => Effect.succeed([])); |
| 93 | | − public hasSessionImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<boolean> => Effect.succeed(false)); |
| 94 | | − public stopAllImpl = vi.fn((): Effect.Effect<void> => Effect.void); |
| 95 | 19 | |
| 96 | | − startSession(input: ProviderSessionStartInput): Effect.Effect<ProviderSession> { |
| 97 | | − return this.startSessionImpl(input); |
| 98 | | − } |
| 20 | +describe("ClaudeCodeAdapterLive", () => { |
| 21 | + const layer = makeClaudeCodeAdapterLive(); |
| 99 | 22 | |
| 100 | | − sendTurn(input: ProviderSendTurnInput): Effect.Effect<ProviderTurnStartResult> { |
| 101 | | − return this.sendTurnImpl(input); |
| 102 | | − } |
| 103 | | − |
| 104 | | − interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Effect.Effect<void> { |
| 105 | | − return this.interruptTurnImpl(sessionId, turnId); |
| 106 | | − } |
| 107 | | − |
| 108 | | − readThread(sessionId: ProviderSessionId) { |
| 109 | | − return this.readThreadImpl(sessionId); |
| 110 | | − } |
| 111 | | − |
| 112 | | − rollbackThread(sessionId: ProviderSessionId, numTurns: number) { |
| 113 | | − return this.rollbackThreadImpl(sessionId, numTurns); |
| 114 | | − } |
| 115 | | − |
| 116 | | − respondToRequest( |
| 117 | | − sessionId: ProviderSessionId, |
| 118 | | − requestId: ApprovalRequestId, |
| 119 | | − decision: ProviderApprovalDecision, |
| 120 | | − ): Effect.Effect<void> { |
| 121 | | − return this.respondToRequestImpl(sessionId, requestId, decision); |
| 122 | | − } |
| 123 | | − |
| 124 | | − stopSession(sessionId: ProviderSessionId): Effect.Effect<void> { |
| 125 | | − return this.stopSessionImpl(sessionId); |
| 126 | | − } |
| 127 | | − |
| 128 | | − listSessions(): Effect.Effect<ReadonlyArray<ProviderSession>> { |
| 129 | | − return this.listSessionsImpl(); |
| 130 | | − } |
| 131 | | − |
| 132 | | − hasSession(sessionId: ProviderSessionId): Effect.Effect<boolean> { |
| 133 | | − return this.hasSessionImpl(sessionId); |
| 134 | | − } |
| 135 | | − |
| 136 | | − stopAll(): Effect.Effect<void> { |
| 137 | | − return this.stopAllImpl(); |
| 138 | | − } |
| 139 | | − |
| 140 | | − emitRuntimeEvent(event: ProviderRuntimeEvent): void { |
| 141 | | − Queue.offerAllUnsafe(this.runtimeEventQueue, [event]); |
| 142 | | − } |
| 143 | | −} |
| 144 | | − |
| 145 | | −const validationRuntime = new FakeClaudeRuntime(); |
| 146 | | −const validationLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: validationRuntime })); |
| 147 | | − |
| 148 | | −validationLayer("ClaudeCodeAdapterLive validation", (it) => { |
| 149 | 23 | it.effect("returns validation error for non-claudeCode provider on startSession", () => |
| 150 | 24 | Effect.gen(function* () { |
| 151 | 25 | const adapter = yield* ClaudeCodeAdapter; |
| 152 | | − const result = yield* adapter |
| 153 | | − .startSession({ |
| 154 | | − provider: "codex", |
| 155 | | − }) |
| 156 | | − .pipe(Effect.result); |
| 26 | + const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result); |
| 157 | 27 | |
| 158 | | − assertFailure( |
| 159 | | − result, |
| 28 | + assert.equal(result._tag, "Failure"); |
| 29 | + if (result._tag !== "Failure") { |
| 30 | + return; |
| 31 | + } |
| 32 | + assert.equal(result.failure._tag, "ProviderAdapterValidationError"); |
| 33 | + if (result.failure._tag !== "ProviderAdapterValidationError") { |
| 34 | + return; |
| 35 | + } |
| 36 | + assert.deepEqual( |
| 37 | + result.failure, |
| 160 | 38 | new ProviderAdapterValidationError({ |
| 161 | 39 | provider: "claudeCode", |
| 162 | 40 | operation: "startSession", |
| 163 | 41 | issue: "Expected provider 'claudeCode' but received 'codex'.", |
| 164 | 42 | }), |
| 165 | 43 | ); |
| 166 | | − assert.equal(validationRuntime.startSessionImpl.mock.calls.length, 0); |
| 167 | | − }), |
| 44 | + }).pipe(Effect.provide(layer)), |
| 168 | 45 | ); |
| 169 | | −}); |
| 170 | 46 | |
| 171 | | −const sessionErrorRuntime = new FakeClaudeRuntime(); |
| 172 | | −sessionErrorRuntime.sendTurnImpl.mockImplementation(() => { |
| 173 | | − return Effect.fail(new Error("Unknown session: claude-sess-missing")); |
| 174 | | −}); |
| 175 | | −const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime })); |
| 47 | + it.effect("returns typed process error when runtime is not configured", () => |
| 48 | + Effect.gen(function* () { |
| 49 | + const adapter = yield* ClaudeCodeAdapter; |
| 50 | + const result = yield* adapter.startSession({ provider: "claudeCode" }).pipe(Effect.result); |
| 176 | 51 | |
| 177 | | −sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { |
| 178 | | − it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => |
| 52 | + assert.equal(result._tag, "Failure"); |
| 53 | + if (result._tag !== "Failure") { |
| 54 | + return; |
| 55 | + } |
| 56 | + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); |
| 57 | + if (result.failure._tag !== "ProviderAdapterProcessError") { |
| 58 | + return; |
| 59 | + } |
| 60 | + assert.deepEqual( |
| 61 | + result.failure, |
| 62 | + new ProviderAdapterProcessError({ |
| 63 | + provider: "claudeCode", |
| 64 | + sessionId: "pending", |
| 65 | + detail: "Claude Code runtime is not configured.", |
| 66 | + }), |
| 67 | + ); |
| 68 | + }).pipe(Effect.provide(layer)), |
| 69 | + ); |
| 70 | + |
| 71 | + it.effect("returns typed request errors for turn operations when runtime is not configured", () => |
| 179 | 72 | Effect.gen(function* () { |
| 180 | 73 | const adapter = yield* ClaudeCodeAdapter; |
| 181 | 74 | const result = yield* adapter |
| 182 | 75 | .sendTurn({ |
| 183 | | − sessionId: asSessionId("claude-sess-missing"), |
| 76 | + sessionId: asSessionId("claude-sess-1"), |
| 184 | 77 | input: "hello", |
| 185 | 78 | attachments: [], |
| 186 | 79 | }) |
| @@ -190,89 +83,84 @@ sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { · 3 unmodified lines |
| 190 | 83 | if (result._tag !== "Failure") { |
| 191 | 84 | return; |
| 192 | 85 | } |
| 193 | | − |
| 194 | | − assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); |
| 195 | | − if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { |
| 86 | + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); |
| 87 | + if (result.failure._tag !== "ProviderAdapterRequestError") { |
| 196 | 88 | return; |
| 197 | 89 | } |
| 198 | | − assert.equal(result.failure.provider, "claudeCode"); |
| 199 | | − assert.equal(result.failure.sessionId, "claude-sess-missing"); |
| 200 | | − assert.instanceOf(result.failure.cause, Error); |
| 201 | | − }), |
| 90 | + assert.deepEqual( |
| 91 | + result.failure, |
| 92 | + new ProviderAdapterRequestError({ |
| 93 | + provider: "claudeCode", |
| 94 | + method: "turn/start", |
| 95 | + detail: "Claude Code runtime is not configured. (session claude-sess-1)", |
| 96 | + }), |
| 97 | + ); |
| 98 | + }).pipe(Effect.provide(layer)), |
| 202 | 99 | ); |
| 203 | | −}); |
| 204 | 100 | |
| 205 | | −const lifecycleRuntime = new FakeClaudeRuntime(); |
| 206 | | −const lifecycleLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: lifecycleRuntime })); |
| 207 | | − |
| 208 | | −lifecycleLayer("ClaudeCodeAdapterLive lifecycle", (it) => { |
| 209 | | − it.effect("forwards providerOptions and opaque resumeCursor to runtime startSession", () => |
| 101 | + it.effect("emits no runtime events by default", () => |
| 210 | 102 | Effect.gen(function* () { |
| 211 | 103 | const adapter = yield* ClaudeCodeAdapter; |
| 212 | | − const resumeCursor = { |
| 213 | | − threadId: "provider-thread-opaque", |
| 214 | | − sessionAt: "message:42", |
| 215 | | − }; |
| 216 | | − const started = yield* adapter.startSession({ |
| 217 | | − provider: "claudeCode", |
| 218 | | − cwd: "/tmp/claude-workspace", |
| 219 | | − model: "claude-sonnet-4", |
| 220 | | − resumeCursor, |
| 221 | | − providerOptions: { |
| 222 | | − claudeCode: { |
| 223 | | − binaryPath: "/usr/local/bin/claude", |
| 224 | | − permissionMode: "acceptEdits", |
| 225 | | − maxThinkingTokens: 4_096, |
| 226 | | − }, |
| 227 | | − }, |
| 228 | | − }); |
| 229 | | − |
| 230 | | − assert.equal(started.provider, "claudeCode"); |
| 231 | | − assert.deepEqual(started.resumeCursor, resumeCursor); |
| 232 | | − assert.deepEqual(lifecycleRuntime.startSessionImpl.mock.calls[0]?.[0], { |
| 233 | | − provider: "claudeCode", |
| 234 | | − cwd: "/tmp/claude-workspace", |
| 235 | | − model: "claude-sonnet-4", |
| 236 | | − resumeCursor, |
| 237 | | − providerOptions: { |
| 238 | | − claudeCode: { |
| 239 | | − binaryPath: "/usr/local/bin/claude", |
| 240 | | − permissionMode: "acceptEdits", |
| 241 | | − maxThinkingTokens: 4_096, |
| 242 | | − }, |
| 243 | | − }, |
| 244 | | − }); |
| 245 | | − }), |
| 104 | + const firstEvent = yield* Stream.runHead(adapter.streamEvents); |
| 105 | + assert.equal(firstEvent._tag, "None"); |
| 106 | + }).pipe(Effect.provide(layer)), |
| 246 | 107 | ); |
| 108 | +}); |
| 247 | 109 | |
| 248 | | − it.effect("passes through canonical runtime events without remapping", () => |
| 110 | +describe("ClaudeCodeAdapter mocked layer", () => { |
| 111 | + it.effect("supports replacing the adapter layer directly in tests", () => |
| 249 | 112 | Effect.gen(function* () { |
| 250 | 113 | const adapter = yield* ClaudeCodeAdapter; |
| 251 | | − const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); |
| 252 | | − const runtimeEvent: ProviderRuntimeEvent = { |
| 253 | | − type: "approval.requested", |
| 254 | | − eventId: asEventId("evt-claude-approval"), |
| 255 | | − provider: "claudeCode", |
| 256 | | − sessionId: asSessionId("claude-sess-1"), |
| 257 | | − createdAt: new Date().toISOString(), |
| 258 | | − requestId: asRequestId("approval-claude-1"), |
| 259 | | − requestKind: "command", |
| 260 | | − }; |
| 261 | | − |
| 262 | | − lifecycleRuntime.emitRuntimeEvent(runtimeEvent); |
| 263 | | − const firstEvent = yield* Fiber.join(firstEventFiber); |
| 264 | | − |
| 265 | | − assert.equal(firstEvent._tag, "Some"); |
| 266 | | − if (firstEvent._tag !== "Some") { |
| 267 | | − return; |
| 268 | | − } |
| 269 | | − assert.deepEqual(firstEvent.value, runtimeEvent); |
| 270 | | − }), |
| 114 | + const started = yield* adapter.startSession({ provider: "claudeCode" }); |
| 115 | + assert.equal(started.sessionId, "mock-sess-1"); |
| 116 | + const event = yield* Stream.runHead(adapter.streamEvents); |
| 117 | + assert.equal(event._tag, "Some"); |
| 118 | + }).pipe( |
| 119 | + Effect.provide( |
| 120 | + Layer.succeed(ClaudeCodeAdapter, { |
| 121 | + provider: "claudeCode", |
| 122 | + startSession: () => |
| 123 | + Effect.succeed({ |
| 124 | + sessionId: asSessionId("mock-sess-1"), |
| 125 | + provider: "claudeCode", |
| 126 | + status: "ready", |
| 127 | + threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 128 | + createdAt: new Date().toISOString(), |
| 129 | + updatedAt: new Date().toISOString(), |
| 130 | + }), |
| 131 | + sendTurn: () => |
| 132 | + Effect.succeed({ |
| 133 | + threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 134 | + turnId: asTurnId("mock-turn-1"), |
| 135 | + }), |
| 136 | + interruptTurn: () => Effect.void, |
| 137 | + readThread: () => |
| 138 | + Effect.succeed({ |
| 139 | + threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 140 | + turns: [], |
| 141 | + }), |
| 142 | + rollbackThread: () => |
| 143 | + Effect.succeed({ |
| 144 | + threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 145 | + turns: [], |
| 146 | + }), |
| 147 | + respondToRequest: () => Effect.void, |
| 148 | + stopSession: () => Effect.void, |
| 149 | + listSessions: () => Effect.succeed([]), |
| 150 | + hasSession: () => Effect.succeed(false), |
| 151 | + stopAll: () => Effect.void, |
| 152 | + streamEvents: Stream.make({ |
| 153 | + type: "turn.completed", |
| 154 | + eventId: asEventId("evt-mock-1"), |
| 155 | + provider: "claudeCode", |
| 156 | + sessionId: asSessionId("mock-sess-1"), |
| 157 | + createdAt: new Date().toISOString(), |
| 158 | + threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 159 | + turnId: asTurnId("mock-turn-1"), |
| 160 | + status: "completed", |
| 161 | + }), |
| 162 | + } satisfies ClaudeCodeAdapterShape), |
| 163 | + ), |
| 164 | + ), |
| 271 | 165 | ); |
| 272 | 166 | }); |
| 273 | | − |
| 274 | | −afterAll(() => { |
| 275 | | − assert.equal(validationRuntime.stopAllImpl.mock.calls.length, 1); |
| 276 | | − assert.equal(sessionErrorRuntime.stopAllImpl.mock.calls.length, 1); |
| 277 | | − assert.equal(lifecycleRuntime.stopAllImpl.mock.calls.length, 1); |
| 278 | | −}); |