| ... | ... | @@ -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) => { |
| 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 | | }); |