| ... | @@ -1,186 +1,79 @@ | ... | @@ -1,186 +1,79 @@ |
| 1 | import type { | 1 | import { EventId, ProviderSessionId, ProviderThreadId, ProviderTurnId } from "@t3tools/contracts"; |
| 2 | ProviderApprovalDecision, | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | ProviderRuntimeEvent, | 3 | import { Effect, Layer, Stream } from "effect"; |
| 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 | | 4 | |
| 21 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 5 | import { |
| 22 | import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; | 6 | ProviderAdapterProcessError, |
| 23 | import { type ClaudeCodeRuntime, makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; | 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 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 16 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 26 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 17 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 27 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 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> { | 20 | describe("ClaudeCodeAdapterLive", () => { |
| 97 | return this.startSessionImpl(input); | 21 | const layer = makeClaudeCodeAdapterLive(); |
| 98 | } | | |
| 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 | it.effect("returns validation error for non-claudeCode provider on startSession", () => | 23 | it.effect("returns validation error for non-claudeCode provider on startSession", () => |
| 150 | Effect.gen(function* () { | 24 | Effect.gen(function* () { |
| 151 | const adapter = yield* ClaudeCodeAdapter; | 25 | const adapter = yield* ClaudeCodeAdapter; |
| 152 | const result = yield* adapter | 26 | const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result); |
| 153 | .startSession({ | | |
| 154 | provider: "codex", | | |
| 155 | }) | | |
| 156 | .pipe(Effect.result); | | |
| 157 | | 27 | |
| 158 | assertFailure( | 28 | assert.equal(result._tag, "Failure"); |
| 159 | result, | 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 | new ProviderAdapterValidationError({ | 38 | new ProviderAdapterValidationError({ |
| 161 | provider: "claudeCode", | 39 | provider: "claudeCode", |
| 162 | operation: "startSession", | 40 | operation: "startSession", |
| 163 | issue: "Expected provider 'claudeCode' but received 'codex'.", | 41 | issue: "Expected provider 'claudeCode' but received 'codex'.", |
| 164 | }), | 42 | }), |
| 165 | ); | 43 | ); |
| 166 | assert.equal(validationRuntime.startSessionImpl.mock.calls.length, 0); | 44 | }).pipe(Effect.provide(layer)), |
| 167 | }), | | |
| 168 | ); | 45 | ); |
| 169 | }); | | |
| 170 | | 46 | |
| 171 | const sessionErrorRuntime = new FakeClaudeRuntime(); | 47 | it.effect("returns typed process error when runtime is not configured", () => |
| 172 | sessionErrorRuntime.sendTurnImpl.mockImplementation(() => { | 48 | Effect.gen(function* () { |
| 173 | return Effect.fail(new Error("Unknown session: claude-sess-missing")); | 49 | const adapter = yield* ClaudeCodeAdapter; |
| 174 | }); | 50 | const result = yield* adapter.startSession({ provider: "claudeCode" }).pipe(Effect.result); |
| 175 | const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime })); | | |
| 176 | | 51 | |
| 177 | sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { | 52 | assert.equal(result._tag, "Failure"); |
| 178 | it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => | 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 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 180 | const adapter = yield* ClaudeCodeAdapter; | 73 | const adapter = yield* ClaudeCodeAdapter; |
| 181 | const result = yield* adapter | 74 | const result = yield* adapter |
| 182 | .sendTurn({ | 75 | .sendTurn({ |
| 183 | sessionId: asSessionId("claude-sess-missing"), | 76 | sessionId: asSessionId("claude-sess-1"), |
| 184 | input: "hello", | 77 | input: "hello", |
| 185 | attachments: [], | 78 | attachments: [], |
| 186 | }) | 79 | }) |
| ... | @@ -190,89 +83,84 @@ sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { | ... | @@ -190,89 +83,84 @@ sessionErrorLayer("ClaudeCodeAdapterLive session errors", (it) => { |
| 190 | if (result._tag !== "Failure") { | 83 | if (result._tag !== "Failure") { |
| 191 | return; | 84 | return; |
| 192 | } | 85 | } |
| 193 | | 86 | assert.equal(result.failure._tag, "ProviderAdapterRequestError"); |
| 194 | assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); | 87 | if (result.failure._tag !== "ProviderAdapterRequestError") { |
| 195 | if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { | | |
| 196 | return; | 88 | return; |
| 197 | } | 89 | } |
| 198 | assert.equal(result.failure.provider, "claudeCode"); | 90 | assert.deepEqual( |
| 199 | assert.equal(result.failure.sessionId, "claude-sess-missing"); | 91 | result.failure, |
| 200 | assert.instanceOf(result.failure.cause, Error); | 92 | new ProviderAdapterRequestError({ |
| 201 | }), | 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(); | 101 | it.effect("emits no runtime events by default", () => |
| 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", () => | | |
| 210 | Effect.gen(function* () { | 102 | Effect.gen(function* () { |
| 211 | const adapter = yield* ClaudeCodeAdapter; | 103 | const adapter = yield* ClaudeCodeAdapter; |
| 212 | const resumeCursor = { | 104 | const firstEvent = yield* Stream.runHead(adapter.streamEvents); |
| 213 | threadId: "provider-thread-opaque", | 105 | assert.equal(firstEvent._tag, "None"); |
| 214 | sessionAt: "message:42", | 106 | }).pipe(Effect.provide(layer)), |
| 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 | }), | | |
| 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 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 250 | const adapter = yield* ClaudeCodeAdapter; | 113 | const adapter = yield* ClaudeCodeAdapter; |
| 251 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | 114 | const started = yield* adapter.startSession({ provider: "claudeCode" }); |
| 252 | const runtimeEvent: ProviderRuntimeEvent = { | 115 | assert.equal(started.sessionId, "mock-sess-1"); |
| 253 | type: "approval.requested", | 116 | const event = yield* Stream.runHead(adapter.streamEvents); |
| 254 | eventId: asEventId("evt-claude-approval"), | 117 | assert.equal(event._tag, "Some"); |
| 255 | provider: "claudeCode", | 118 | }).pipe( |
| 256 | sessionId: asSessionId("claude-sess-1"), | 119 | Effect.provide( |
| 257 | createdAt: new Date().toISOString(), | 120 | Layer.succeed(ClaudeCodeAdapter, { |
| 258 | requestId: asRequestId("approval-claude-1"), | 121 | provider: "claudeCode", |
| 259 | requestKind: "command", | 122 | startSession: () => |
| 260 | }; | 123 | Effect.succeed({ |
| 261 | | 124 | sessionId: asSessionId("mock-sess-1"), |
| 262 | lifecycleRuntime.emitRuntimeEvent(runtimeEvent); | 125 | provider: "claudeCode", |
| 263 | const firstEvent = yield* Fiber.join(firstEventFiber); | 126 | status: "ready", |
| 264 | | 127 | threadId: ProviderThreadId.makeUnsafe("mock-thread-1"), |
| 265 | assert.equal(firstEvent._tag, "Some"); | 128 | createdAt: new Date().toISOString(), |
| 266 | if (firstEvent._tag !== "Some") { | 129 | updatedAt: new Date().toISOString(), |
| 267 | return; | 130 | }), |
| 268 | } | 131 | sendTurn: () => |
| 269 | assert.deepEqual(firstEvent.value, runtimeEvent); | 132 | Effect.succeed({ |
| 270 | }), | 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 | }); | | |