authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 09:09:05-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log1b1ede92bae7e925827f9961fe322c55e79241c3
tree59577ddeab9a70003c4a27571bafef75f21dd5fe
parent4ec7ae33c5e47dc638bbfb80bd792724f5d7f1ce

Make Claude runtime boundary Effect-native

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 110 insertions(+), 111 deletions(-)

apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+46-37
...@@ -32,9 +32,9 @@ class FakeClaudeRuntime implements ClaudeCodeRuntime {...@@ -32,9 +32,9 @@ class FakeClaudeRuntime implements ClaudeCodeRuntime {
32 readonly streamEvents = Stream.fromQueue(this.runtimeEventQueue);32 readonly streamEvents = Stream.fromQueue(this.runtimeEventQueue);
3333
34 public startSessionImpl = vi.fn(34 public startSessionImpl = vi.fn(
35 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {35 (input: ProviderSessionStartInput): Effect.Effect<ProviderSession> => {
36 const now = new Date().toISOString();36 const now = new Date().toISOString();
37 return {37 return Effect.succeed({
38 sessionId: asSessionId("claude-sess-1"),38 sessionId: asSessionId("claude-sess-1"),
39 provider: "claudeCode",39 provider: "claudeCode",
40 status: "ready",40 status: "ready",
...@@ -44,88 +44,97 @@ class FakeClaudeRuntime implements ClaudeCodeRuntime {...@@ -44,88 +44,97 @@ class FakeClaudeRuntime implements ClaudeCodeRuntime {
44 model: input.model,44 model: input.model,
45 createdAt: now,45 createdAt: now,
46 updatedAt: now,46 updatedAt: now,
47 };47 });
48 },48 },
49 );49 );
5050
51 public sendTurnImpl = vi.fn(51 public sendTurnImpl = vi.fn(
52 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({52 (_input: ProviderSendTurnInput): Effect.Effect<ProviderTurnStartResult> =>
53 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),53 Effect.succeed({
54 turnId: asTurnId("claude-turn-1"),54 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
55 }),55 turnId: asTurnId("claude-turn-1"),
56 }),
56 );57 );
5758
58 public interruptTurnImpl = vi.fn(59 public interruptTurnImpl = vi.fn(
59 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,60 (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Effect.Effect<void> => Effect.void,
60 );61 );
6162
62 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({63 public readThreadImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<{
63 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),64 threadId: ProviderThreadId;
64 turns: [],65 turns: never[];
65 }));66 }> =>
6667 Effect.succeed({
67 public rollbackThreadImpl = vi.fn(
68 async (_sessionId: ProviderSessionId, _numTurns: number) => ({
69 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),68 threadId: ProviderThreadId.makeUnsafe("claude-thread-1"),
70 turns: [],69 turns: [],
71 }),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 }),
72 );81 );
7382
74 public respondToRequestImpl = vi.fn(83 public respondToRequestImpl = vi.fn(
75 async (84 (
76 _sessionId: ProviderSessionId,85 _sessionId: ProviderSessionId,
77 _requestId: ApprovalRequestId,86 _requestId: ApprovalRequestId,
78 _decision: ProviderApprovalDecision,87 _decision: ProviderApprovalDecision,
79 ): Promise<void> => undefined,88 ): Effect.Effect<void> => Effect.void,
80 );89 );
8190
82 public stopSessionImpl = vi.fn((_sessionId: ProviderSessionId) => undefined);91 public stopSessionImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<void> => Effect.void);
83 public listSessionsImpl = vi.fn((): ReadonlyArray<ProviderSession> => []);92 public listSessionsImpl = vi.fn((): Effect.Effect<ReadonlyArray<ProviderSession>> => Effect.succeed([]));
84 public hasSessionImpl = vi.fn((_sessionId: ProviderSessionId): boolean => false);93 public hasSessionImpl = vi.fn((_sessionId: ProviderSessionId): Effect.Effect<boolean> => Effect.succeed(false));
85 public stopAllImpl = vi.fn(() => undefined);94 public stopAllImpl = vi.fn((): Effect.Effect<void> => Effect.void);
8695
87 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {96 startSession(input: ProviderSessionStartInput): Effect.Effect<ProviderSession> {
88 return this.startSessionImpl(input);97 return this.startSessionImpl(input);
89 }98 }
9099
91 async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {100 sendTurn(input: ProviderSendTurnInput): Effect.Effect<ProviderTurnStartResult> {
92 return this.sendTurnImpl(input);101 return this.sendTurnImpl(input);
93 }102 }
94103
95 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {104 interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Effect.Effect<void> {
96 return this.interruptTurnImpl(sessionId, turnId);105 return this.interruptTurnImpl(sessionId, turnId);
97 }106 }
98107
99 async readThread(sessionId: ProviderSessionId) {108 readThread(sessionId: ProviderSessionId) {
100 return this.readThreadImpl(sessionId);109 return this.readThreadImpl(sessionId);
101 }110 }
102111
103 async rollbackThread(sessionId: ProviderSessionId, numTurns: number) {112 rollbackThread(sessionId: ProviderSessionId, numTurns: number) {
104 return this.rollbackThreadImpl(sessionId, numTurns);113 return this.rollbackThreadImpl(sessionId, numTurns);
105 }114 }
106115
107 async respondToRequest(116 respondToRequest(
108 sessionId: ProviderSessionId,117 sessionId: ProviderSessionId,
109 requestId: ApprovalRequestId,118 requestId: ApprovalRequestId,
110 decision: ProviderApprovalDecision,119 decision: ProviderApprovalDecision,
111 ): Promise<void> {120 ): Effect.Effect<void> {
112 return this.respondToRequestImpl(sessionId, requestId, decision);121 return this.respondToRequestImpl(sessionId, requestId, decision);
113 }122 }
114123
115 stopSession(sessionId: ProviderSessionId): void {124 stopSession(sessionId: ProviderSessionId): Effect.Effect<void> {
116 this.stopSessionImpl(sessionId);125 return this.stopSessionImpl(sessionId);
117 }126 }
118127
119 listSessions(): ReadonlyArray<ProviderSession> {128 listSessions(): Effect.Effect<ReadonlyArray<ProviderSession>> {
120 return this.listSessionsImpl();129 return this.listSessionsImpl();
121 }130 }
122131
123 hasSession(sessionId: ProviderSessionId): boolean {132 hasSession(sessionId: ProviderSessionId): Effect.Effect<boolean> {
124 return this.hasSessionImpl(sessionId);133 return this.hasSessionImpl(sessionId);
125 }134 }
126135
127 stopAll(): void {136 stopAll(): Effect.Effect<void> {
128 this.stopAllImpl();137 return this.stopAllImpl();
129 }138 }
130139
131 emitRuntimeEvent(event: ProviderRuntimeEvent): void {140 emitRuntimeEvent(event: ProviderRuntimeEvent): void {
...@@ -160,8 +169,8 @@ validationLayer("ClaudeCodeAdapterLive validation", (it) => {...@@ -160,8 +169,8 @@ validationLayer("ClaudeCodeAdapterLive validation", (it) => {
160});169});
161170
162const sessionErrorRuntime = new FakeClaudeRuntime();171const sessionErrorRuntime = new FakeClaudeRuntime();
163sessionErrorRuntime.sendTurnImpl.mockImplementation(async () => {172sessionErrorRuntime.sendTurnImpl.mockImplementation(() => {
164 throw new Error("Unknown session: claude-sess-missing");173 return Effect.fail(new Error("Unknown session: claude-sess-missing"));
165});174});
166const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime }));175const sessionErrorLayer = it.layer(makeClaudeCodeAdapterLive({ runtime: sessionErrorRuntime }));
167176
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+64-74
...@@ -36,23 +36,32 @@ import {...@@ -36,23 +36,32 @@ import {
36const PROVIDER = "claudeCode" as const;36const PROVIDER = "claudeCode" as const;
3737
38export interface ClaudeCodeRuntime {38export interface ClaudeCodeRuntime {
39 readonly startSession: (input: ProviderSessionStartInput) => Promise<ProviderSession>;39 readonly startSession: (
40 readonly sendTurn: (input: ProviderSendTurnInput) => Promise<ProviderTurnStartResult>;40 input: ProviderSessionStartInput,
41 readonly interruptTurn: (sessionId: ProviderSessionId, turnId?: ProviderTurnId) => Promise<void>;41 ) => Effect.Effect<ProviderSession, unknown>;
42 readonly readThread: (sessionId: ProviderSessionId) => Promise<ProviderThreadSnapshot>;42 readonly sendTurn: (
43 input: ProviderSendTurnInput,
44 ) => Effect.Effect<ProviderTurnStartResult, unknown>;
45 readonly interruptTurn: (
46 sessionId: ProviderSessionId,
47 turnId?: ProviderTurnId,
48 ) => Effect.Effect<void, unknown>;
49 readonly readThread: (
50 sessionId: ProviderSessionId,
51 ) => Effect.Effect<ProviderThreadSnapshot, unknown>;
43 readonly rollbackThread: (52 readonly rollbackThread: (
44 sessionId: ProviderSessionId,53 sessionId: ProviderSessionId,
45 numTurns: number,54 numTurns: number,
46 ) => Promise<ProviderThreadSnapshot>;55 ) => Effect.Effect<ProviderThreadSnapshot, unknown>;
47 readonly respondToRequest: (56 readonly respondToRequest: (
48 sessionId: ProviderSessionId,57 sessionId: ProviderSessionId,
49 requestId: ApprovalRequestId,58 requestId: ApprovalRequestId,
50 decision: ProviderApprovalDecision,59 decision: ProviderApprovalDecision,
51 ) => Promise<void>;60 ) => Effect.Effect<void, unknown>;
52 readonly stopSession: (sessionId: ProviderSessionId) => void;61 readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void>;
53 readonly listSessions: () => ReadonlyArray<ProviderSession>;62 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;
54 readonly hasSession: (sessionId: ProviderSessionId) => boolean;63 readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>;
55 readonly stopAll: () => void;64 readonly stopAll: () => Effect.Effect<void>;
56 readonly streamEvents: Stream.Stream<ProviderRuntimeEvent>;65 readonly streamEvents: Stream.Stream<ProviderRuntimeEvent>;
57}66}
5867
...@@ -107,22 +116,20 @@ function toRequestError(...@@ -107,22 +116,20 @@ function toRequestError(
107 });116 });
108}117}
109118
110function makeUnavailableRuntime(): ClaudeCodeRuntime {119const unavailableRuntimeError = () => new Error("Claude Code runtime is not configured.");
111 const unavailable = async (): Promise<never> => {
112 throw new Error("Claude Code runtime is not configured.");
113 };
114120
121function makeUnavailableRuntime(): ClaudeCodeRuntime {
115 return {122 return {
116 startSession: unavailable,123 startSession: () => Effect.fail(unavailableRuntimeError()),
117 sendTurn: unavailable,124 sendTurn: () => Effect.fail(unavailableRuntimeError()),
118 interruptTurn: unavailable,125 interruptTurn: () => Effect.fail(unavailableRuntimeError()),
119 readThread: unavailable,126 readThread: () => Effect.fail(unavailableRuntimeError()),
120 rollbackThread: unavailable,127 rollbackThread: () => Effect.fail(unavailableRuntimeError()),
121 respondToRequest: unavailable,128 respondToRequest: () => Effect.fail(unavailableRuntimeError()),
122 stopSession: () => {},129 stopSession: () => Effect.void,
123 listSessions: () => [],130 listSessions: () => Effect.succeed([]),
124 hasSession: () => false,131 hasSession: () => Effect.succeed(false),
125 stopAll: () => {},132 stopAll: () => Effect.void,
126 streamEvents: Stream.empty,133 streamEvents: Stream.empty,
127 };134 };
128}135}
...@@ -140,13 +147,9 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>...@@ -140,13 +147,9 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
140 return makeUnavailableRuntime();147 return makeUnavailableRuntime();
141 }),148 }),
142 (runtime) =>149 (runtime) =>
143 Effect.sync(() => {150 runtime.stopAll().pipe(
144 try {151 Effect.orElseSucceed(() => undefined),
145 runtime.stopAll();152 ),
146 } catch {
147 // Finalizers should never fail and block shutdown.
148 }
149 }),
150 );153 );
151154
152 const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => {155 const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => {
...@@ -160,35 +163,33 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>...@@ -160,35 +163,33 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
160 );163 );
161 }164 }
162165
163 return Effect.tryPromise({166 return runtime.startSession(input).pipe(
164 try: () => runtime.startSession(input),167 Effect.mapError(
165 catch: (cause) =>168 (cause) =>
166 new ProviderAdapterProcessError({169 new ProviderAdapterProcessError({
167 provider: PROVIDER,170 provider: PROVIDER,
168 sessionId: "pending",171 sessionId: "pending",
169 detail: toMessage(cause, "Failed to start Claude Code adapter session."),172 detail: toMessage(cause, "Failed to start Claude Code adapter session."),
170 cause,173 cause,
171 }),174 }),
172 });175 ),
176 );
173 };177 };
174178
175 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>179 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
176 Effect.tryPromise({180 runtime.sendTurn(input).pipe(
177 try: () => runtime.sendTurn(input),181 Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)),
178 catch: (cause) => toRequestError(input.sessionId, "turn/start", cause),182 );
179 });
180183
181 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, turnId) =>184 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, turnId) =>
182 Effect.tryPromise({185 runtime.interruptTurn(sessionId, turnId).pipe(
183 try: () => runtime.interruptTurn(sessionId, turnId),186 Effect.mapError((cause) => toRequestError(sessionId, "turn/interrupt", cause)),
184 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),187 );
185 });
186188
187 const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) =>189 const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) =>
188 Effect.tryPromise({190 runtime.readThread(sessionId).pipe(
189 try: () => runtime.readThread(sessionId),191 Effect.mapError((cause) => toRequestError(sessionId, "thread/read", cause)),
190 catch: (cause) => toRequestError(sessionId, "thread/read", cause),192 );
191 });
192193
193 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => {194 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => {
194 if (!Number.isInteger(numTurns) || numTurns < 1) {195 if (!Number.isInteger(numTurns) || numTurns < 1) {
...@@ -201,10 +202,9 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>...@@ -201,10 +202,9 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
201 );202 );
202 }203 }
203204
204 return Effect.tryPromise({205 return runtime.rollbackThread(sessionId, numTurns).pipe(
205 try: () => runtime.rollbackThread(sessionId, numTurns),206 Effect.mapError((cause) => toRequestError(sessionId, "thread/rollback", cause)),
206 catch: (cause) => toRequestError(sessionId, "thread/rollback", cause),207 );
207 });
208 };208 };
209209
210 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (210 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
...@@ -212,26 +212,17 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>...@@ -212,26 +212,17 @@ const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
212 requestId,212 requestId,
213 decision,213 decision,
214 ) =>214 ) =>
215 Effect.tryPromise({215 runtime.respondToRequest(sessionId, requestId, decision).pipe(
216 try: () => runtime.respondToRequest(sessionId, requestId, decision),216 Effect.mapError((cause) => toRequestError(sessionId, "item/requestApproval/decision", cause)),
217 catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause),217 );
218 });
219218
220 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) =>219 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) => runtime.stopSession(sessionId);
221 Effect.sync(() => {
222 runtime.stopSession(sessionId);
223 });
224220
225 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>221 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => runtime.listSessions();
226 Effect.sync(() => runtime.listSessions());
227222
228 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) =>223 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) => runtime.hasSession(sessionId);
229 Effect.sync(() => runtime.hasSession(sessionId));
230224
231 const stopAll: ClaudeCodeAdapterShape["stopAll"] = () =>225 const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => runtime.stopAll();
232 Effect.sync(() => {
233 runtime.stopAll();
234 });
235226
236 return {227 return {
237 provider: PROVIDER,228 provider: PROVIDER,
...@@ -254,4 +245,3 @@ export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeC...@@ -254,4 +245,3 @@ export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeC
254export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) {245export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) {
255 return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options));246 return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options));
256}247}
257