authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 20:00:52-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log2e5e75699ef57f93967dffa64be9a70a7eec63a4
tree237729454adea8a07c45821d76bcf13f241877cc
parentbe308beacc92e7309ec7f00e7084e6ee954ad8ae

Remove Claude adapter from stack base

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

9 files changed, 6 insertions(+), 2868 deletions(-)

apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts deleted-930
......@@ -1,930 +0,0 @@
1import type {
2 Options as ClaudeQueryOptions,
3 PermissionMode,
4 PermissionResult,
5 SDKMessage,
6 SDKUserMessage,
7} from "@anthropic-ai/claude-agent-sdk";
8import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
9import { assert, describe, it } from "@effect/vitest";
10import { Effect, Fiber, Random, Stream } from "effect";
11
12import {
13 ProviderAdapterValidationError,
14} from "../Errors.ts";
15import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";
16import {
17 makeClaudeCodeAdapterLive,
18 type ClaudeCodeAdapterLiveOptions,
19} from "./ClaudeCodeAdapter.ts";
20
21class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
22 private readonly queue: Array<SDKMessage> = [];
23 private readonly resolvers: Array<(value: IteratorResult<SDKMessage>) => void> = [];
24 private done = false;
25
26 public readonly interruptCalls: Array<void> = [];
27 public readonly setModelCalls: Array<string | undefined> = [];
28 public readonly setPermissionModeCalls: Array<string> = [];
29 public readonly setMaxThinkingTokensCalls: Array<number | null> = [];
30 public closeCalls = 0;
31
32 emit(message: SDKMessage): void {
33 if (this.done) {
34 return;
35 }
36 const resolver = this.resolvers.shift();
37 if (resolver) {
38 resolver({ done: false, value: message });
39 return;
40 }
41 this.queue.push(message);
42 }
43
44 finish(): void {
45 if (this.done) {
46 return;
47 }
48 this.done = true;
49 for (const resolver of this.resolvers.splice(0)) {
50 resolver({ done: true, value: undefined });
51 }
52 }
53
54 readonly interrupt = async (): Promise<void> => {
55 this.interruptCalls.push(undefined);
56 };
57
58 readonly setModel = async (model?: string): Promise<void> => {
59 this.setModelCalls.push(model);
60 };
61
62 readonly setPermissionMode = async (mode: PermissionMode): Promise<void> => {
63 this.setPermissionModeCalls.push(mode);
64 };
65
66 readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise<void> => {
67 this.setMaxThinkingTokensCalls.push(maxThinkingTokens);
68 };
69
70 readonly close = (): void => {
71 this.closeCalls += 1;
72 this.finish();
73 };
74
75 [Symbol.asyncIterator](): AsyncIterator<SDKMessage> {
76 return {
77 next: () => {
78 if (this.queue.length > 0) {
79 const value = this.queue.shift();
80 if (value) {
81 return Promise.resolve({
82 done: false,
83 value,
84 });
85 }
86 }
87 if (this.done) {
88 return Promise.resolve({
89 done: true,
90 value: undefined,
91 });
92 }
93 return new Promise((resolve) => {
94 this.resolvers.push(resolve);
95 });
96 },
97 };
98 }
99}
100
101interface Harness {
102 readonly layer: ReturnType<typeof makeClaudeCodeAdapterLive>;
103 readonly query: FakeClaudeQuery;
104 readonly getLastCreateQueryInput: () =>
105 | {
106 readonly prompt: AsyncIterable<SDKUserMessage>;
107 readonly options: ClaudeQueryOptions;
108 }
109 | undefined;
110}
111
112function makeHarness(config?: {
113 readonly nativeEventLogPath?: string;
114 readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"];
115}): Harness {
116 const query = new FakeClaudeQuery();
117 let createInput:
118 | {
119 readonly prompt: AsyncIterable<SDKUserMessage>;
120 readonly options: ClaudeQueryOptions;
121 }
122 | undefined;
123
124 const adapterOptions: ClaudeCodeAdapterLiveOptions = {
125 createQuery: (input) => {
126 createInput = input;
127 return query;
128 },
129 ...(config?.nativeEventLogger
130 ? {
131 nativeEventLogger: config.nativeEventLogger,
132 }
133 : {}),
134 ...(config?.nativeEventLogPath
135 ? {
136 nativeEventLogPath: config.nativeEventLogPath,
137 }
138 : {}),
139 };
140
141 return {
142 layer: makeClaudeCodeAdapterLive(adapterOptions),
143 query,
144 getLastCreateQueryInput: () => createInput,
145 };
146}
147
148function makeDeterministicRandomService(seed = 0x1234_5678): {
149 nextIntUnsafe: () => number;
150 nextDoubleUnsafe: () => number;
151} {
152 let state = seed >>> 0;
153 const nextIntUnsafe = (): number => {
154 state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0;
155 return state;
156 };
157
158 return {
159 nextIntUnsafe,
160 nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000,
161 };
162}
163
164
165const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
166const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
167
168describe("ClaudeCodeAdapterLive", () => {
169 it.effect("returns validation error for non-claudeCode provider on startSession", () => {
170 const harness = makeHarness();
171 return Effect.gen(function* () {
172 const adapter = yield* ClaudeCodeAdapter;
173 const result = yield* adapter
174 .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" })
175 .pipe(Effect.result);
176
177 assert.equal(result._tag, "Failure");
178 if (result._tag !== "Failure") {
179 return;
180 }
181 assert.deepEqual(
182 result.failure,
183 new ProviderAdapterValidationError({
184 provider: "claudeCode",
185 operation: "startSession",
186 issue: "Expected provider 'claudeCode' but received 'codex'.",
187 }),
188 );
189 }).pipe(
190 Effect.provideService(Random.Random, makeDeterministicRandomService()),
191 Effect.provide(harness.layer),
192 );
193 });
194
195 it.effect("derives bypass permission mode from full-access runtime policy", () => {
196 const harness = makeHarness();
197 return Effect.gen(function* () {
198 const adapter = yield* ClaudeCodeAdapter;
199 yield* adapter.startSession({
200 threadId: THREAD_ID,
201 provider: "claudeCode",
202 runtimeMode: "full-access",
203 });
204
205 const createInput = harness.getLastCreateQueryInput();
206 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
207 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
208 }).pipe(
209 Effect.provideService(Random.Random, makeDeterministicRandomService()),
210 Effect.provide(harness.layer),
211 );
212 });
213
214 it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => {
215 const harness = makeHarness();
216 return Effect.gen(function* () {
217 const adapter = yield* ClaudeCodeAdapter;
218 yield* adapter.startSession({
219 threadId: THREAD_ID,
220 provider: "claudeCode",
221 runtimeMode: "full-access",
222 providerOptions: {
223 claudeCode: {
224 permissionMode: "plan",
225 },
226 },
227 });
228
229 const createInput = harness.getLastCreateQueryInput();
230 assert.equal(createInput?.options.permissionMode, "plan");
231 assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);
232 }).pipe(
233 Effect.provideService(Random.Random, makeDeterministicRandomService()),
234 Effect.provide(harness.layer),
235 );
236 });
237
238 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
239 const harness = makeHarness();
240 return Effect.gen(function* () {
241 const adapter = yield* ClaudeCodeAdapter;
242
243 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe(
244 Stream.runCollect,
245 Effect.forkChild,
246 );
247
248 const session = yield* adapter.startSession({
249 threadId: THREAD_ID,
250 provider: "claudeCode",
251 model: "claude-sonnet-4-5",
252 runtimeMode: "full-access",
253 });
254
255 const turn = yield* adapter.sendTurn({
256 threadId: session.threadId,
257 input: "hello",
258 attachments: [],
259 });
260
261 harness.query.emit({
262 type: "stream_event",
263 session_id: "sdk-session-1",
264 uuid: "stream-1",
265 parent_tool_use_id: null,
266 event: {
267 type: "content_block_delta",
268 index: 0,
269 delta: {
270 type: "text_delta",
271 text: "Hi",
272 },
273 },
274 } as unknown as SDKMessage);
275
276 harness.query.emit({
277 type: "stream_event",
278 session_id: "sdk-session-1",
279 uuid: "stream-2",
280 parent_tool_use_id: null,
281 event: {
282 type: "content_block_start",
283 index: 1,
284 content_block: {
285 type: "tool_use",
286 id: "tool-1",
287 name: "Bash",
288 input: {
289 command: "ls",
290 },
291 },
292 },
293 } as unknown as SDKMessage);
294
295 harness.query.emit({
296 type: "stream_event",
297 session_id: "sdk-session-1",
298 uuid: "stream-3",
299 parent_tool_use_id: null,
300 event: {
301 type: "content_block_stop",
302 index: 1,
303 },
304 } as unknown as SDKMessage);
305
306 harness.query.emit({
307 type: "assistant",
308 session_id: "sdk-session-1",
309 uuid: "assistant-1",
310 parent_tool_use_id: null,
311 message: {
312 id: "assistant-message-1",
313 content: [{ type: "text", text: "Hi" }],
314 },
315 } as unknown as SDKMessage);
316
317 harness.query.emit({
318 type: "result",
319 subtype: "success",
320 is_error: false,
321 errors: [],
322 session_id: "sdk-session-1",
323 uuid: "result-1",
324 } as unknown as SDKMessage);
325
326 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
327 assert.deepEqual(
328 runtimeEvents.map((event) => event.type),
329 [
330 "session.started",
331 "session.configured",
332 "session.state.changed",
333 "turn.started",
334 "thread.started",
335 "content.delta",
336 "item.started",
337 "item.completed",
338 "item.updated",
339 "item.completed",
340 "turn.completed",
341 ],
342 );
343
344 const turnStarted = runtimeEvents[3];
345 assert.equal(turnStarted?.type, "turn.started");
346 if (turnStarted?.type === "turn.started") {
347 assert.equal(String(turnStarted.turnId), String(turn.turnId));
348 }
349
350 const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta");
351 assert.equal(deltaEvent?.type, "content.delta");
352 if (deltaEvent?.type === "content.delta") {
353 assert.equal(deltaEvent.payload.delta, "Hi");
354 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
355 }
356
357 const toolStarted = runtimeEvents.find((event) => event.type === "item.started");
358 assert.equal(toolStarted?.type, "item.started");
359 if (toolStarted?.type === "item.started") {
360 assert.equal(toolStarted.payload.itemType, "command_execution");
361 }
362
363 const turnCompleted = runtimeEvents[runtimeEvents.length - 1];
364 assert.equal(turnCompleted?.type, "turn.completed");
365 if (turnCompleted?.type === "turn.completed") {
366 assert.equal(String(turnCompleted.turnId), String(turn.turnId));
367 assert.equal(turnCompleted.payload.state, "completed");
368 }
369 }).pipe(
370 Effect.provideService(Random.Random, makeDeterministicRandomService()),
371 Effect.provide(harness.layer),
372 );
373 });
374
375 it.effect("emits completion only after turn result when assistant frames arrive before deltas", () => {
376 const harness = makeHarness();
377 return Effect.gen(function* () {
378 const adapter = yield* ClaudeCodeAdapter;
379
380 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
381 Stream.runCollect,
382 Effect.forkChild,
383 );
384
385 const session = yield* adapter.startSession({
386 threadId: THREAD_ID,
387 provider: "claudeCode",
388 runtimeMode: "full-access",
389 });
390
391 const turn = yield* adapter.sendTurn({
392 threadId: session.threadId,
393 input: "hello",
394 attachments: [],
395 });
396
397 harness.query.emit({
398 type: "assistant",
399 session_id: "sdk-session-early-assistant",
400 uuid: "assistant-early",
401 parent_tool_use_id: null,
402 message: {
403 id: "assistant-message-early",
404 content: [{ type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }],
405 },
406 } as unknown as SDKMessage);
407
408 harness.query.emit({
409 type: "stream_event",
410 session_id: "sdk-session-early-assistant",
411 uuid: "stream-early",
412 parent_tool_use_id: null,
413 event: {
414 type: "content_block_delta",
415 index: 0,
416 delta: {
417 type: "text_delta",
418 text: "Late text",
419 },
420 },
421 } as unknown as SDKMessage);
422
423 harness.query.emit({
424 type: "result",
425 subtype: "success",
426 is_error: false,
427 errors: [],
428 session_id: "sdk-session-early-assistant",
429 uuid: "result-early",
430 } as unknown as SDKMessage);
431
432 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
433 assert.deepEqual(
434 runtimeEvents.map((event) => event.type),
435 [
436 "session.started",
437 "session.configured",
438 "session.state.changed",
439 "turn.started",
440 "thread.started",
441 "item.updated",
442 "content.delta",
443 "item.completed",
444 "turn.completed",
445 ],
446 );
447
448 const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta");
449 const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed");
450 assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true);
451
452 const deltaEvent = runtimeEvents[deltaIndex];
453 assert.equal(deltaEvent?.type, "content.delta");
454 if (deltaEvent?.type === "content.delta") {
455 assert.equal(deltaEvent.payload.delta, "Late text");
456 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
457 }
458 }).pipe(
459 Effect.provideService(Random.Random, makeDeterministicRandomService()),
460 Effect.provide(harness.layer),
461 );
462 });
463
464 it.effect("falls back to assistant payload text when stream deltas are absent", () => {
465 const harness = makeHarness();
466 return Effect.gen(function* () {
467 const adapter = yield* ClaudeCodeAdapter;
468
469 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
470 Stream.runCollect,
471 Effect.forkChild,
472 );
473
474 const session = yield* adapter.startSession({
475 threadId: THREAD_ID,
476 provider: "claudeCode",
477 runtimeMode: "full-access",
478 });
479
480 const turn = yield* adapter.sendTurn({
481 threadId: session.threadId,
482 input: "hello",
483 attachments: [],
484 });
485
486 harness.query.emit({
487 type: "assistant",
488 session_id: "sdk-session-fallback-text",
489 uuid: "assistant-fallback",
490 parent_tool_use_id: null,
491 message: {
492 id: "assistant-message-fallback",
493 content: [{ type: "text", text: "Fallback hello" }],
494 },
495 } as unknown as SDKMessage);
496
497 harness.query.emit({
498 type: "result",
499 subtype: "success",
500 is_error: false,
501 errors: [],
502 session_id: "sdk-session-fallback-text",
503 uuid: "result-fallback",
504 } as unknown as SDKMessage);
505
506 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
507 assert.deepEqual(
508 runtimeEvents.map((event) => event.type),
509 [
510 "session.started",
511 "session.configured",
512 "session.state.changed",
513 "turn.started",
514 "thread.started",
515 "item.updated",
516 "content.delta",
517 "item.completed",
518 "turn.completed",
519 ],
520 );
521
522 const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta");
523 assert.equal(deltaEvent?.type, "content.delta");
524 if (deltaEvent?.type === "content.delta") {
525 assert.equal(deltaEvent.payload.delta, "Fallback hello");
526 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
527 }
528 }).pipe(
529 Effect.provideService(Random.Random, makeDeterministicRandomService()),
530 Effect.provide(harness.layer),
531 );
532 });
533
534 it.effect("does not fabricate provider thread ids before first SDK session_id", () => {
535 const harness = makeHarness();
536 return Effect.gen(function* () {
537 const adapter = yield* ClaudeCodeAdapter;
538
539 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
540 Stream.runCollect,
541 Effect.forkChild,
542 );
543
544 const session = yield* adapter.startSession({
545 threadId: THREAD_ID,
546 provider: "claudeCode",
547 runtimeMode: "full-access",
548 });
549 assert.equal(session.threadId, undefined);
550
551 const turn = yield* adapter.sendTurn({
552 threadId: session.threadId,
553 input: "hello",
554 attachments: [],
555 });
556 assert.equal(turn.threadId, undefined);
557
558 harness.query.emit({
559 type: "stream_event",
560 session_id: "sdk-thread-real",
561 uuid: "stream-thread-real",
562 parent_tool_use_id: null,
563 event: {
564 type: "message_start",
565 message: {
566 id: "msg-thread-real",
567 },
568 },
569 } as unknown as SDKMessage);
570
571 harness.query.emit({
572 type: "result",
573 subtype: "success",
574 is_error: false,
575 errors: [],
576 session_id: "sdk-thread-real",
577 uuid: "result-thread-real",
578 } as unknown as SDKMessage);
579
580 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
581 assert.deepEqual(
582 runtimeEvents.map((event) => event.type),
583 [
584 "session.started",
585 "session.configured",
586 "session.state.changed",
587 "turn.started",
588 "thread.started",
589 ],
590 );
591
592 const sessionStarted = runtimeEvents[0];
593 assert.equal(sessionStarted?.type, "session.started");
594 if (sessionStarted?.type === "session.started") {
595 assert.equal("threadId" in sessionStarted, false);
596 }
597
598 const threadStarted = runtimeEvents[4];
599 assert.equal(threadStarted?.type, "thread.started");
600 if (threadStarted?.type === "thread.started") {
601 assert.equal(threadStarted.threadId, "sdk-thread-real");
602 }
603 }).pipe(
604 Effect.provideService(Random.Random, makeDeterministicRandomService()),
605 Effect.provide(harness.layer),
606 );
607 });
608
609 it.effect("bridges approval request/response lifecycle through canUseTool", () => {
610 const harness = makeHarness();
611 return Effect.gen(function* () {
612 const adapter = yield* ClaudeCodeAdapter;
613
614 const session = yield* adapter.startSession({
615 threadId: THREAD_ID,
616 provider: "claudeCode",
617 runtimeMode: "approval-required",
618 });
619
620 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
621
622 const createInput = harness.getLastCreateQueryInput();
623 const canUseTool = createInput?.options.canUseTool;
624 assert.equal(typeof canUseTool, "function");
625 if (!canUseTool) {
626 return;
627 }
628
629 const permissionPromise = canUseTool(
630 "Bash",
631 { command: "pwd" },
632 {
633 signal: new AbortController().signal,
634 suggestions: [
635 {
636 type: "setMode",
637 mode: "default",
638 destination: "session",
639 },
640 ],
641 toolUseID: "tool-use-1",
642 },
643 );
644
645 const requested = yield* Stream.runHead(adapter.streamEvents);
646 assert.equal(requested._tag, "Some");
647 if (requested._tag !== "Some") {
648 return;
649 }
650 assert.equal(requested.value.type, "request.opened");
651 if (requested.value.type !== "request.opened") {
652 return;
653 }
654 const runtimeRequestId = requested.value.requestId;
655 assert.equal(typeof runtimeRequestId, "string");
656 if (runtimeRequestId === undefined) {
657 return;
658 }
659
660 yield* adapter.respondToRequest(
661 session.threadId,
662 ApprovalRequestId.makeUnsafe(runtimeRequestId),
663 "accept",
664 );
665
666 const resolved = yield* Stream.runHead(adapter.streamEvents);
667 assert.equal(resolved._tag, "Some");
668 if (resolved._tag !== "Some") {
669 return;
670 }
671 assert.equal(resolved.value.type, "request.resolved");
672 if (resolved.value.type !== "request.resolved") {
673 return;
674 }
675 assert.equal(resolved.value.requestId, requested.value.requestId);
676 assert.equal(resolved.value.payload.decision, "accept");
677
678 const permissionResult = yield* Effect.promise(() => permissionPromise);
679 assert.equal((permissionResult as PermissionResult).behavior, "allow");
680 }).pipe(
681 Effect.provideService(Random.Random, makeDeterministicRandomService()),
682 Effect.provide(harness.layer),
683 );
684 });
685
686 it.effect("passes parsed resume cursor values to Claude query options", () => {
687 const harness = makeHarness();
688 return Effect.gen(function* () {
689 const adapter = yield* ClaudeCodeAdapter;
690
691 const session = yield* adapter.startSession({
692 threadId: RESUME_THREAD_ID,
693 provider: "claudeCode",
694 resumeCursor: {
695 threadId: "resume-thread-1",
696 resume: "550e8400-e29b-41d4-a716-446655440000",
697 resumeSessionAt: "assistant-99",
698 turnCount: 3,
699 },
700 runtimeMode: "full-access",
701 });
702
703 assert.equal(session.threadId, "resume-thread-1");
704 assert.deepEqual(session.resumeCursor, {
705 threadId: "resume-thread-1",
706 resume: "550e8400-e29b-41d4-a716-446655440000",
707 resumeSessionAt: "assistant-99",
708 turnCount: 3,
709 });
710
711 const createInput = harness.getLastCreateQueryInput();
712 assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000");
713 assert.equal(createInput?.options.resumeSessionAt, "assistant-99");
714 }).pipe(
715 Effect.provideService(Random.Random, makeDeterministicRandomService()),
716 Effect.provide(harness.layer),
717 );
718 });
719
720 it.effect("does not synthesize resume session id from generated thread ids", () => {
721 const harness = makeHarness();
722 return Effect.gen(function* () {
723 const adapter = yield* ClaudeCodeAdapter;
724
725 const session = yield* adapter.startSession({
726 threadId: THREAD_ID,
727 provider: "claudeCode",
728 runtimeMode: "full-access",
729 });
730
731 assert.equal(
732 "resume" in (session.resumeCursor as Record<string, unknown>),
733 false,
734 );
735
736 const createInput = harness.getLastCreateQueryInput();
737 assert.equal(createInput?.options.resume, undefined);
738 }).pipe(
739 Effect.provideService(Random.Random, makeDeterministicRandomService()),
740 Effect.provide(harness.layer),
741 );
742 });
743
744 it.effect("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => {
745 const harness = makeHarness();
746 return Effect.gen(function* () {
747 const adapter = yield* ClaudeCodeAdapter;
748
749 const session = yield* adapter.startSession({
750 threadId: THREAD_ID,
751 provider: "claudeCode",
752 runtimeMode: "full-access",
753 });
754
755 const firstTurn = yield* adapter.sendTurn({
756 threadId: session.threadId,
757 input: "first",
758 attachments: [],
759 });
760
761 const firstCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe(
762 Stream.runHead,
763 Effect.forkChild,
764 );
765
766 harness.query.emit({
767 type: "result",
768 subtype: "success",
769 is_error: false,
770 errors: [],
771 session_id: "sdk-session-rollback",
772 uuid: "result-first",
773 } as unknown as SDKMessage);
774
775 const firstCompleted = yield* Fiber.join(firstCompletedFiber);
776 assert.equal(firstCompleted._tag, "Some");
777 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {
778 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));
779 }
780
781 const secondTurn = yield* adapter.sendTurn({
782 threadId: session.threadId,
783 input: "second",
784 attachments: [],
785 });
786
787 const secondCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe(
788 Stream.runHead,
789 Effect.forkChild,
790 );
791
792 harness.query.emit({
793 type: "result",
794 subtype: "success",
795 is_error: false,
796 errors: [],
797 session_id: "sdk-session-rollback",
798 uuid: "result-second",
799 } as unknown as SDKMessage);
800
801 const secondCompleted = yield* Fiber.join(secondCompletedFiber);
802 assert.equal(secondCompleted._tag, "Some");
803 if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") {
804 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
805 }
806
807 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
808 assert.equal(threadBeforeRollback.turns.length, 2);
809
810 const rolledBack = yield* adapter.rollbackThread(session.threadId, 1);
811 assert.equal(rolledBack.turns.length, 1);
812 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId);
813
814 const threadAfterRollback = yield* adapter.readThread(session.threadId);
815 assert.equal(threadAfterRollback.turns.length, 1);
816 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId);
817 }).pipe(
818 Effect.provideService(Random.Random, makeDeterministicRandomService()),
819 Effect.provide(harness.layer),
820 );
821 });
822
823 it.effect("updates model on sendTurn when model override is provided", () => {
824 const harness = makeHarness();
825 return Effect.gen(function* () {
826 const adapter = yield* ClaudeCodeAdapter;
827
828 const session = yield* adapter.startSession({
829 threadId: THREAD_ID,
830 provider: "claudeCode",
831 runtimeMode: "full-access",
832 });
833 yield* adapter.sendTurn({
834 threadId: session.threadId,
835 input: "hello",
836 model: "claude-opus-4-6",
837 attachments: [],
838 });
839
840 assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]);
841 }).pipe(
842 Effect.provideService(Random.Random, makeDeterministicRandomService()),
843 Effect.provide(harness.layer),
844 );
845 });
846
847 it.effect("writes provider-native observability records when enabled", () => {
848 const nativeEvents: Array<{
849 event?: {
850 provider?: string;
851 method?: string;
852 threadId?: string;
853 turnId?: string;
854 };
855 }> = [];
856 const harness = makeHarness({
857 nativeEventLogger: {
858 filePath: "memory://claude-native-events",
859 write: (event) => {
860 nativeEvents.push(event as (typeof nativeEvents)[number]);
861 return Effect.void;
862 },
863 close: () => Effect.void,
864 },
865 });
866 return Effect.gen(function* () {
867 const adapter = yield* ClaudeCodeAdapter;
868
869 const session = yield* adapter.startSession({
870 threadId: THREAD_ID,
871 provider: "claudeCode",
872 runtimeMode: "full-access",
873 });
874 const turn = yield* adapter.sendTurn({
875 threadId: session.threadId,
876 input: "hello",
877 attachments: [],
878 });
879
880 const turnCompletedFiber = yield* Stream.filter(
881 adapter.streamEvents,
882 (event) => event.type === "turn.completed",
883 ).pipe(Stream.runHead, Effect.forkChild);
884
885 harness.query.emit({
886 type: "stream_event",
887 session_id: "sdk-session-native-log",
888 uuid: "stream-native-log",
889 parent_tool_use_id: null,
890 event: {
891 type: "content_block_delta",
892 index: 0,
893 delta: {
894 type: "text_delta",
895 text: "hi",
896 },
897 },
898 } as unknown as SDKMessage);
899
900 harness.query.emit({
901 type: "result",
902 subtype: "success",
903 is_error: false,
904 errors: [],
905 session_id: "sdk-session-native-log",
906 uuid: "result-native-log",
907 } as unknown as SDKMessage);
908
909 const turnCompleted = yield* Fiber.join(turnCompletedFiber);
910 assert.equal(turnCompleted._tag, "Some");
911
912 assert.equal(nativeEvents.length > 0, true);
913 assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true);
914 assert.equal(nativeEvents.some((record) => String(record.event?.threadId) === String(session.threadId)), true);
915 assert.equal(
916 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),
917 true,
918 );
919 assert.equal(
920 nativeEvents.some(
921 (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta",
922 ),
923 true,
924 );
925 }).pipe(
926 Effect.provideService(Random.Random, makeDeterministicRandomService()),
927 Effect.provide(harness.layer),
928 );
929 });
930});
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts deleted-1857
......@@ -1,1857 +0,0 @@
1/**
2 * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter.
3 *
4 * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic
5 * provider adapter contract and emits canonical runtime events.
6 *
7 * @module ClaudeCodeAdapterLive
8 */
9import {
10 type CanUseTool,
11 query,
12 type Options as ClaudeQueryOptions,
13 type PermissionMode,
14 type PermissionResult,
15 type PermissionUpdate,
16 type SDKMessage,
17 type SDKResultMessage,
18 type SDKUserMessage,
19} from "@anthropic-ai/claude-agent-sdk";
20import {
21 ApprovalRequestId,
22 type CanonicalItemType,
23 type CanonicalRequestType,
24 EventId,
25 type ProviderApprovalDecision,
26 ProviderItemId,
27 type ProviderRuntimeEvent,
28 type ProviderRuntimeTurnStatus,
29 type ProviderSendTurnInput,
30 type ProviderSession,
31 RuntimeItemId,
32 RuntimeRequestId,
33 RuntimeTaskId,
34 ThreadId,
35 TurnId,
36} from "@t3tools/contracts";
37import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect";
38
39import {
40 ProviderAdapterProcessError,
41 ProviderAdapterRequestError,
42 ProviderAdapterSessionClosedError,
43 ProviderAdapterSessionNotFoundError,
44 ProviderAdapterValidationError,
45 type ProviderAdapterError,
46} from "../Errors.ts";
47import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
48import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
49
50const PROVIDER = "claudeCode" as const;
51
52type PromptQueueItem =
53 | {
54 readonly type: "message";
55 readonly message: SDKUserMessage;
56 }
57 | {
58 readonly type: "terminate";
59 };
60
61interface ClaudeResumeState {
62 readonly threadId?: ThreadId;
63 readonly resume?: string;
64 readonly resumeSessionAt?: string;
65 readonly turnCount?: number;
66}
67
68interface ClaudeTurnState {
69 readonly turnId: TurnId;
70 readonly assistantItemId: string;
71 readonly startedAt: string;
72 readonly items: Array<unknown>;
73 readonly messageCompleted: boolean;
74 readonly emittedTextDelta: boolean;
75 readonly fallbackAssistantText: string;
76}
77
78interface PendingApproval {
79 readonly requestType: CanonicalRequestType;
80 readonly detail?: string;
81 readonly suggestions?: ReadonlyArray<PermissionUpdate>;
82 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
83}
84
85interface ToolInFlight {
86 readonly itemId: string;
87 readonly itemType: CanonicalItemType;
88 readonly toolName: string;
89 readonly title: string;
90 readonly detail?: string;
91}
92
93interface ClaudeSessionContext {
94 session: ProviderSession;
95 readonly promptQueue: Queue.Queue<PromptQueueItem>;
96 readonly query: ClaudeQueryRuntime;
97 readonly startedAt: string;
98 resumeSessionId: string | undefined;
99 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
100 readonly turns: Array<{
101 id: TurnId;
102 items: Array<unknown>;
103 }>;
104 readonly inFlightTools: Map<number, ToolInFlight>;
105 turnState: ClaudeTurnState | undefined;
106 lastAssistantUuid: string | undefined;
107 lastThreadStartedId: string | undefined;
108 stopped: boolean;
109}
110
111interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
112 readonly interrupt: () => Promise<void>;
113 readonly setModel: (model?: string) => Promise<void>;
114 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
115 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
116 readonly close: () => void;
117}
118
119export interface ClaudeCodeAdapterLiveOptions {
120 readonly createQuery?: (input: {
121 readonly prompt: AsyncIterable<SDKUserMessage>;
122 readonly options: ClaudeQueryOptions;
123 }) => ClaudeQueryRuntime;
124 readonly nativeEventLogPath?: string;
125 readonly nativeEventLogger?: EventNdjsonLogger;
126}
127
128function isUuid(value: string): boolean {
129 return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
130}
131
132function isSyntheticClaudeThreadId(value: string): boolean {
133 return value.startsWith("claude-thread-");
134}
135
136function toMessage(cause: unknown, fallback: string): string {
137 if (cause instanceof Error && cause.message.length > 0) {
138 return cause.message;
139 }
140 return fallback;
141}
142
143function asRuntimeItemId(value: string): RuntimeItemId {
144 return RuntimeItemId.makeUnsafe(value);
145}
146
147function asCanonicalTurnId(value: TurnId): TurnId {
148 return value;
149}
150
151function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
152 return RuntimeRequestId.makeUnsafe(value);
153}
154
155function toPermissionMode(value: unknown): PermissionMode | undefined {
156 switch (value) {
157 case "default":
158 case "acceptEdits":
159 case "bypassPermissions":
160 case "plan":
161 case "dontAsk":
162 return value;
163 default:
164 return undefined;
165 }
166}
167
168function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined {
169 if (!resumeCursor || typeof resumeCursor !== "object") {
170 return undefined;
171 }
172 const cursor = resumeCursor as {
173 threadId?: unknown;
174 resume?: unknown;
175 sessionId?: unknown;
176 resumeSessionAt?: unknown;
177 turnCount?: unknown;
178 };
179
180 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
181 const threadId =
182 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
183 ? ThreadId.makeUnsafe(threadIdCandidate)
184 : undefined;
185 const resumeCandidate =
186 typeof cursor.resume === "string"
187 ? cursor.resume
188 : typeof cursor.sessionId === "string"
189 ? cursor.sessionId
190 : undefined;
191 const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined;
192 const resumeSessionAt =
193 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
194 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
195
196 return {
197 ...(threadId ? { threadId } : {}),
198 ...(resume ? { resume } : {}),
199 ...(resumeSessionAt ? { resumeSessionAt } : {}),
200 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
201 ? { turnCount: turnCountValue }
202 : {}),
203 };
204}
205
206function classifyToolItemType(toolName: string): CanonicalItemType {
207 const normalized = toolName.toLowerCase();
208 if (
209 normalized.includes("bash") ||
210 normalized.includes("command") ||
211 normalized.includes("shell") ||
212 normalized.includes("terminal")
213 ) {
214 return "command_execution";
215 }
216 if (
217 normalized.includes("edit") ||
218 normalized.includes("write") ||
219 normalized.includes("file") ||
220 normalized.includes("patch") ||
221 normalized.includes("replace") ||
222 normalized.includes("create") ||
223 normalized.includes("delete")
224 ) {
225 return "file_change";
226 }
227 if (normalized.includes("mcp")) {
228 return "mcp_tool_call";
229 }
230 return "dynamic_tool_call";
231}
232
233function classifyRequestType(toolName: string): CanonicalRequestType {
234 const normalized = toolName.toLowerCase();
235 if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) {
236 return "file_read_approval";
237 }
238 return classifyToolItemType(toolName) === "command_execution"
239 ? "command_execution_approval"
240 : "file_change_approval";
241}
242
243function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
244 const commandValue = input.command ?? input.cmd;
245 const command = typeof commandValue === "string" ? commandValue : undefined;
246 if (command && command.trim().length > 0) {
247 return `${toolName}: ${command.trim().slice(0, 400)}`;
248 }
249
250 const serialized = JSON.stringify(input);
251 if (serialized.length <= 400) {
252 return `${toolName}: ${serialized}`;
253 }
254 return `${toolName}: ${serialized.slice(0, 397)}...`;
255}
256
257function titleForTool(itemType: CanonicalItemType): string {
258 switch (itemType) {
259 case "command_execution":
260 return "Command run";
261 case "file_change":
262 return "File change";
263 case "mcp_tool_call":
264 return "MCP tool call";
265 case "dynamic_tool_call":
266 return "Tool call";
267 default:
268 return "Item";
269 }
270}
271
272function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage {
273 const fragments: string[] = [];
274
275 if (input.input && input.input.trim().length > 0) {
276 fragments.push(input.input.trim());
277 }
278
279 for (const attachment of input.attachments ?? []) {
280 if (attachment.type === "image") {
281 fragments.push(
282 `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`,
283 );
284 }
285 }
286
287 const text = fragments.join("\n\n");
288
289 return {
290 type: "user",
291 session_id: "",
292 parent_tool_use_id: null,
293 message: {
294 role: "user",
295 content: [{ type: "text", text }],
296 },
297 } as SDKUserMessage;
298}
299
300function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
301 if (result.subtype === "success") {
302 return "completed";
303 }
304
305 const errors = result.errors.join(" ").toLowerCase();
306 if (errors.includes("interrupt")) {
307 return "interrupted";
308 }
309 if (errors.includes("cancel")) {
310 return "cancelled";
311 }
312 return "failed";
313}
314
315function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" {
316 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";
317}
318
319function providerThreadRef(
320 context: ClaudeSessionContext,
321): { readonly providerThreadId: string } | {} {
322 return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {};
323}
324
325function extractAssistantText(message: SDKMessage): string {
326 if (message.type !== "assistant") {
327 return "";
328 }
329
330 const content = (message.message as { content?: unknown } | undefined)?.content;
331 if (!Array.isArray(content)) {
332 return "";
333 }
334
335 const fragments: string[] = [];
336 for (const block of content) {
337 if (!block || typeof block !== "object") {
338 continue;
339 }
340 const candidate = block as { type?: unknown; text?: unknown };
341 if (
342 candidate.type === "text" &&
343 typeof candidate.text === "string" &&
344 candidate.text.length > 0
345 ) {
346 fragments.push(candidate.text);
347 }
348 }
349
350 return fragments.join("");
351}
352
353function toSessionError(
354 threadId: ThreadId,
355 cause: unknown,
356): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
357 const normalized = toMessage(cause, "").toLowerCase();
358 if (normalized.includes("unknown session") || normalized.includes("not found")) {
359 return new ProviderAdapterSessionNotFoundError({
360 provider: PROVIDER,
361 threadId,
362 cause,
363 });
364 }
365 if (normalized.includes("closed")) {
366 return new ProviderAdapterSessionClosedError({
367 provider: PROVIDER,
368 threadId,
369 cause,
370 });
371 }
372 return undefined;
373}
374
375function toRequestError(
376 threadId: ThreadId,
377 method: string,
378 cause: unknown,
379): ProviderAdapterError {
380 const sessionError = toSessionError(threadId, cause);
381 if (sessionError) {
382 return sessionError;
383 }
384 return new ProviderAdapterRequestError({
385 provider: PROVIDER,
386 method,
387 detail: toMessage(cause, `${method} failed`),
388 cause,
389 });
390}
391
392function sdkMessageType(value: unknown): string | undefined {
393 if (!value || typeof value !== "object") {
394 return undefined;
395 }
396 const record = value as { type?: unknown };
397 return typeof record.type === "string" ? record.type : undefined;
398}
399
400function sdkMessageSubtype(value: unknown): string | undefined {
401 if (!value || typeof value !== "object") {
402 return undefined;
403 }
404 const record = value as { subtype?: unknown };
405 return typeof record.subtype === "string" ? record.subtype : undefined;
406}
407
408function sdkNativeMethod(message: SDKMessage): string {
409 const subtype = sdkMessageSubtype(message);
410 if (subtype) {
411 return `claude/${message.type}/${subtype}`;
412 }
413
414 if (message.type === "stream_event") {
415 const streamType = sdkMessageType(message.event);
416 if (streamType) {
417 const deltaType =
418 streamType === "content_block_delta"
419 ? sdkMessageType((message.event as { delta?: unknown }).delta)
420 : undefined;
421 if (deltaType) {
422 return `claude/${message.type}/${streamType}/${deltaType}`;
423 }
424 return `claude/${message.type}/${streamType}`;
425 }
426 }
427
428 return `claude/${message.type}`;
429}
430
431function sdkNativeItemId(message: SDKMessage): string | undefined {
432 if (message.type === "assistant") {
433 const maybeId = (message.message as { id?: unknown }).id;
434 if (typeof maybeId === "string") {
435 return maybeId;
436 }
437 return undefined;
438 }
439
440 if (message.type === "stream_event") {
441 const event = message.event as {
442 type?: unknown;
443 content_block?: { id?: unknown };
444 };
445 if (event.type === "content_block_start" && typeof event.content_block?.id === "string") {
446 return event.content_block.id;
447 }
448 }
449
450 return undefined;
451}
452
453function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
454 return Effect.gen(function* () {
455 const nativeEventLogger =
456 options?.nativeEventLogger ??
457 (options?.nativeEventLogPath !== undefined
458 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
459 stream: "native",
460 })
461 : undefined);
462
463 const createQuery =
464 options?.createQuery ??
465 ((input: {
466 readonly prompt: AsyncIterable<SDKUserMessage>;
467 readonly options: ClaudeQueryOptions;
468 }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);
469
470 const sessions = new Map<ThreadId, ClaudeSessionContext>();
471 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
472
473 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
474 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
475 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
476
477 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
478 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
479
480 const logNativeSdkMessage = (
481 context: ClaudeSessionContext,
482 message: SDKMessage,
483 ): Effect.Effect<void> =>
484 Effect.gen(function* () {
485 if (!nativeEventLogger) {
486 return;
487 }
488
489 const observedAt = new Date().toISOString();
490 const itemId = sdkNativeItemId(message);
491
492 yield* nativeEventLogger
493 .write(
494 {
495 observedAt,
496 event: {
497 id:
498 "uuid" in message && typeof message.uuid === "string"
499 ? message.uuid
500 : crypto.randomUUID(),
501 kind: "notification",
502 provider: PROVIDER,
503 createdAt: observedAt,
504 method: sdkNativeMethod(message),
505 ...(typeof message.session_id === "string"
506 ? { providerThreadId: message.session_id }
507 : {}),
508 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
509 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),
510 payload: message,
511 },
512 },
513 null,
514 );
515 });
516
517 const snapshotThread = (
518 context: ClaudeSessionContext,
519 ): Effect.Effect<{
520 threadId: ThreadId;
521 turns: ReadonlyArray<{
522 id: TurnId;
523 items: ReadonlyArray<unknown>;
524 }>;
525 }, ProviderAdapterValidationError> =>
526 Effect.gen(function* () {
527 const threadId = context.session.threadId;
528 if (!threadId) {
529 return yield* new ProviderAdapterValidationError({
530 provider: PROVIDER,
531 operation: "readThread",
532 issue: "Session thread id is not initialized yet.",
533 });
534 }
535 return {
536 threadId,
537 turns: context.turns.map((turn) => ({
538 id: turn.id,
539 items: [...turn.items],
540 })),
541 };
542 });
543
544 const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect<void> =>
545 Effect.gen(function* () {
546 const threadId = context.session.threadId;
547 if (!threadId) return;
548
549 const resumeCursor = {
550 threadId,
551 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
552 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
553 turnCount: context.turns.length,
554 };
555
556 context.session = {
557 ...context.session,
558 resumeCursor,
559 updatedAt: yield* nowIso,
560 };
561 });
562
563 const ensureThreadId = (
564 context: ClaudeSessionContext,
565 message: SDKMessage,
566 ): Effect.Effect<void> =>
567 Effect.gen(function* () {
568 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
569 return;
570 }
571 const nextThreadId = message.session_id;
572 context.resumeSessionId = message.session_id;
573 yield* updateResumeCursor(context);
574
575 if (context.lastThreadStartedId !== nextThreadId) {
576 context.lastThreadStartedId = nextThreadId;
577 const stamp = yield* makeEventStamp();
578 yield* offerRuntimeEvent({
579 type: "thread.started",
580 eventId: stamp.eventId,
581 provider: PROVIDER,
582 createdAt: stamp.createdAt,
583 threadId: context.session.threadId,
584 payload: {
585 providerThreadId: nextThreadId,
586 },
587 providerRefs: {},
588 raw: {
589 source: "claude.sdk.message",
590 method: "claude/thread/started",
591 payload: {
592 session_id: message.session_id,
593 },
594 },
595 });
596 }
597 });
598
599 const emitRuntimeError = (
600 context: ClaudeSessionContext,
601 message: string,
602 cause?: unknown,
603 ): Effect.Effect<void> =>
604 Effect.gen(function* () {
605 if (cause !== undefined) {
606 void cause;
607 }
608 const turnState = context.turnState;
609 const stamp = yield* makeEventStamp();
610 yield* offerRuntimeEvent({
611 type: "runtime.error",
612 eventId: stamp.eventId,
613 provider: PROVIDER,
614 createdAt: stamp.createdAt,
615 threadId: context.session.threadId,
616 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
617 payload: {
618 message,
619 class: "provider_error",
620 ...(cause !== undefined ? { detail: cause } : {}),
621 },
622 providerRefs: {
623 ...providerThreadRef(context),
624 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
625 },
626 });
627 });
628
629 const emitRuntimeWarning = (
630 context: ClaudeSessionContext,
631 message: string,
632 detail?: unknown,
633 ): Effect.Effect<void> =>
634 Effect.gen(function* () {
635 const turnState = context.turnState;
636 const stamp = yield* makeEventStamp();
637 yield* offerRuntimeEvent({
638 type: "runtime.warning",
639 eventId: stamp.eventId,
640 provider: PROVIDER,
641 createdAt: stamp.createdAt,
642 threadId: context.session.threadId,
643 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
644 payload: {
645 message,
646 ...(detail !== undefined ? { detail } : {}),
647 },
648 providerRefs: {
649 ...providerThreadRef(context),
650 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
651 },
652 });
653 });
654
655 const completeTurn = (
656 context: ClaudeSessionContext,
657 status: ProviderRuntimeTurnStatus,
658 errorMessage?: string,
659 result?: SDKResultMessage,
660 ): Effect.Effect<void> =>
661 Effect.gen(function* () {
662 const turnState = context.turnState;
663 if (!turnState) {
664 const stamp = yield* makeEventStamp();
665 yield* offerRuntimeEvent({
666 type: "turn.completed",
667 eventId: stamp.eventId,
668 provider: PROVIDER,
669 createdAt: stamp.createdAt,
670 threadId: context.session.threadId,
671 payload: {
672 state: status,
673 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
674 ...(result?.usage ? { usage: result.usage } : {}),
675 ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}),
676 ...(typeof result?.total_cost_usd === "number"
677 ? { totalCostUsd: result.total_cost_usd }
678 : {}),
679 ...(errorMessage ? { errorMessage } : {}),
680 },
681 providerRefs: {},
682 });
683 return;
684 }
685
686 if (!turnState.messageCompleted) {
687 if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) {
688 const deltaStamp = yield* makeEventStamp();
689 yield* offerRuntimeEvent({
690 type: "content.delta",
691 eventId: deltaStamp.eventId,
692 provider: PROVIDER,
693 createdAt: deltaStamp.createdAt,
694 threadId: context.session.threadId,
695 turnId: turnState.turnId,
696 itemId: asRuntimeItemId(turnState.assistantItemId),
697 payload: {
698 streamKind: "assistant_text",
699 delta: turnState.fallbackAssistantText,
700 },
701 providerRefs: {
702 ...providerThreadRef(context),
703 providerTurnId: String(turnState.turnId),
704 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
705 },
706 });
707 }
708
709 const stamp = yield* makeEventStamp();
710 yield* offerRuntimeEvent({
711 type: "item.completed",
712 eventId: stamp.eventId,
713 provider: PROVIDER,
714 createdAt: stamp.createdAt,
715 itemId: asRuntimeItemId(turnState.assistantItemId),
716 threadId: context.session.threadId,
717 turnId: turnState.turnId,
718 payload: {
719 itemType: "assistant_message",
720 status: "completed",
721 title: "Assistant message",
722 },
723 providerRefs: {
724 ...providerThreadRef(context),
725 providerTurnId: turnState.turnId,
726 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
727 },
728 });
729 }
730
731 context.turns.push({
732 id: turnState.turnId,
733 items: [...turnState.items],
734 });
735
736 const stamp = yield* makeEventStamp();
737 yield* offerRuntimeEvent({
738 type: "turn.completed",
739 eventId: stamp.eventId,
740 provider: PROVIDER,
741 createdAt: stamp.createdAt,
742 threadId: context.session.threadId,
743 turnId: turnState.turnId,
744 payload: {
745 state: status,
746 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
747 ...(result?.usage ? { usage: result.usage } : {}),
748 ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}),
749 ...(typeof result?.total_cost_usd === "number"
750 ? { totalCostUsd: result.total_cost_usd }
751 : {}),
752 ...(errorMessage ? { errorMessage } : {}),
753 },
754 providerRefs: {
755 ...providerThreadRef(context),
756 providerTurnId: turnState.turnId,
757 },
758 });
759
760 const updatedAt = yield* nowIso;
761 context.turnState = undefined;
762 context.session = {
763 ...context.session,
764 status: "ready",
765 activeTurnId: undefined,
766 updatedAt,
767 ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}),
768 };
769 yield* updateResumeCursor(context);
770 });
771
772 const handleStreamEvent = (
773 context: ClaudeSessionContext,
774 message: SDKMessage,
775 ): Effect.Effect<void> =>
776 Effect.gen(function* () {
777 if (message.type !== "stream_event") {
778 return;
779 }
780
781 const { event } = message;
782
783 if (event.type === "content_block_delta") {
784 if (
785 event.delta.type === "text_delta" &&
786 event.delta.text.length > 0 &&
787 context.turnState
788 ) {
789 if (!context.turnState.emittedTextDelta) {
790 context.turnState = {
791 ...context.turnState,
792 emittedTextDelta: true,
793 };
794 }
795 const stamp = yield* makeEventStamp();
796 yield* offerRuntimeEvent({
797 type: "content.delta",
798 eventId: stamp.eventId,
799 provider: PROVIDER,
800 createdAt: stamp.createdAt,
801 threadId: context.session.threadId,
802 turnId: context.turnState.turnId,
803 itemId: asRuntimeItemId(context.turnState.assistantItemId),
804 payload: {
805 streamKind: streamKindFromDeltaType(event.delta.type),
806 delta: event.delta.text,
807 },
808 providerRefs: {
809 ...providerThreadRef(context),
810 providerTurnId: context.turnState.turnId,
811 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
812 },
813 raw: {
814 source: "claude.sdk.message",
815 method: "claude/stream_event/content_block_delta",
816 payload: message,
817 },
818 });
819 }
820 return;
821 }
822
823 if (event.type === "content_block_start") {
824 const { index, content_block: block } = event;
825 if (
826 block.type !== "tool_use" &&
827 block.type !== "server_tool_use" &&
828 block.type !== "mcp_tool_use"
829 ) {
830 return;
831 }
832
833 const toolName = block.name;
834 const itemType = classifyToolItemType(toolName);
835 const toolInput =
836 typeof block.input === "object" && block.input !== null
837 ? (block.input as Record<string, unknown>)
838 : {};
839 const itemId = block.id;
840 const detail = summarizeToolRequest(toolName, toolInput);
841
842 const tool: ToolInFlight = {
843 itemId,
844 itemType,
845 toolName,
846 title: titleForTool(itemType),
847 detail,
848 };
849 context.inFlightTools.set(index, tool);
850
851 const stamp = yield* makeEventStamp();
852 yield* offerRuntimeEvent({
853 type: "item.started",
854 eventId: stamp.eventId,
855 provider: PROVIDER,
856 createdAt: stamp.createdAt,
857 threadId: context.session.threadId,
858 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
859 itemId: asRuntimeItemId(tool.itemId),
860 payload: {
861 itemType: tool.itemType,
862 status: "inProgress",
863 title: tool.title,
864 ...(tool.detail ? { detail: tool.detail } : {}),
865 data: {
866 toolName: tool.toolName,
867 input: toolInput,
868 },
869 },
870 providerRefs: {
871 ...providerThreadRef(context),
872 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
873 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
874 },
875 raw: {
876 source: "claude.sdk.message",
877 method: "claude/stream_event/content_block_start",
878 payload: message,
879 },
880 });
881 return;
882 }
883
884 if (event.type === "content_block_stop") {
885 const { index } = event;
886 const tool = context.inFlightTools.get(index);
887 if (!tool) {
888 return;
889 }
890 context.inFlightTools.delete(index);
891
892 const stamp = yield* makeEventStamp();
893 yield* offerRuntimeEvent({
894 type: "item.completed",
895 eventId: stamp.eventId,
896 provider: PROVIDER,
897 createdAt: stamp.createdAt,
898 threadId: context.session.threadId,
899 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
900 itemId: asRuntimeItemId(tool.itemId),
901 payload: {
902 itemType: tool.itemType,
903 status: "completed",
904 title: tool.title,
905 ...(tool.detail ? { detail: tool.detail } : {}),
906 },
907 providerRefs: {
908 ...providerThreadRef(context),
909 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
910 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
911 },
912 raw: {
913 source: "claude.sdk.message",
914 method: "claude/stream_event/content_block_stop",
915 payload: message,
916 },
917 });
918 }
919 });
920
921 const handleAssistantMessage = (
922 context: ClaudeSessionContext,
923 message: SDKMessage,
924 ): Effect.Effect<void> =>
925 Effect.gen(function* () {
926 if (message.type !== "assistant") {
927 return;
928 }
929
930 if (context.turnState) {
931 context.turnState.items.push(message.message);
932 const fallbackAssistantText = extractAssistantText(message);
933 if (
934 fallbackAssistantText.length > 0 &&
935 fallbackAssistantText !== context.turnState.fallbackAssistantText
936 ) {
937 context.turnState = {
938 ...context.turnState,
939 fallbackAssistantText,
940 };
941 }
942
943 const stamp = yield* makeEventStamp();
944 yield* offerRuntimeEvent({
945 type: "item.updated",
946 eventId: stamp.eventId,
947 provider: PROVIDER,
948 createdAt: stamp.createdAt,
949 threadId: context.session.threadId,
950 turnId: context.turnState.turnId,
951 itemId: asRuntimeItemId(context.turnState.assistantItemId),
952 payload: {
953 itemType: "assistant_message",
954 status: "inProgress",
955 title: "Assistant message",
956 data: message.message,
957 },
958 providerRefs: {
959 ...providerThreadRef(context),
960 providerTurnId: context.turnState.turnId,
961 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
962 },
963 raw: {
964 source: "claude.sdk.message",
965 method: "claude/assistant",
966 payload: message,
967 },
968 });
969 }
970
971 context.lastAssistantUuid = message.uuid;
972 yield* updateResumeCursor(context);
973 });
974
975 const handleResultMessage = (
976 context: ClaudeSessionContext,
977 message: SDKMessage,
978 ): Effect.Effect<void> =>
979 Effect.gen(function* () {
980 if (message.type !== "result") {
981 return;
982 }
983
984 const status = turnStatusFromResult(message);
985 const errorMessage = message.subtype === "success" ? undefined : message.errors[0];
986
987 if (status === "failed") {
988 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
989 }
990
991 yield* completeTurn(context, status, errorMessage, message);
992 });
993
994 const handleSystemMessage = (
995 context: ClaudeSessionContext,
996 message: SDKMessage,
997 ): Effect.Effect<void> =>
998 Effect.gen(function* () {
999 if (message.type !== "system") {
1000 return;
1001 }
1002
1003 const stamp = yield* makeEventStamp();
1004 const base = {
1005 eventId: stamp.eventId,
1006 provider: PROVIDER,
1007 createdAt: stamp.createdAt,
1008 threadId: context.session.threadId,
1009 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1010 providerRefs: {
1011 ...providerThreadRef(context),
1012 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1013 },
1014 raw: {
1015 source: "claude.sdk.message" as const,
1016 method: sdkNativeMethod(message),
1017 messageType: `${message.type}:${message.subtype}`,
1018 payload: message,
1019 },
1020 };
1021
1022 switch (message.subtype) {
1023 case "init":
1024 yield* offerRuntimeEvent({
1025 ...base,
1026 type: "session.configured",
1027 payload: {
1028 config: message as Record<string, unknown>,
1029 },
1030 });
1031 return;
1032 case "status":
1033 yield* offerRuntimeEvent({
1034 ...base,
1035 type: "session.state.changed",
1036 payload: {
1037 state: message.status === "compacting" ? "waiting" : "running",
1038 reason: `status:${message.status ?? "active"}`,
1039 detail: message,
1040 },
1041 });
1042 return;
1043 case "compact_boundary":
1044 yield* offerRuntimeEvent({
1045 ...base,
1046 type: "thread.state.changed",
1047 payload: {
1048 state: "compacted",
1049 detail: message,
1050 },
1051 });
1052 return;
1053 case "hook_started":
1054 yield* offerRuntimeEvent({
1055 ...base,
1056 type: "hook.started",
1057 payload: {
1058 hookId: message.hook_id,
1059 hookName: message.hook_name,
1060 hookEvent: message.hook_event,
1061 },
1062 });
1063 return;
1064 case "hook_progress":
1065 yield* offerRuntimeEvent({
1066 ...base,
1067 type: "hook.progress",
1068 payload: {
1069 hookId: message.hook_id,
1070 output: message.output,
1071 stdout: message.stdout,
1072 stderr: message.stderr,
1073 },
1074 });
1075 return;
1076 case "hook_response":
1077 yield* offerRuntimeEvent({
1078 ...base,
1079 type: "hook.completed",
1080 payload: {
1081 hookId: message.hook_id,
1082 outcome: message.outcome,
1083 output: message.output,
1084 stdout: message.stdout,
1085 stderr: message.stderr,
1086 ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}),
1087 },
1088 });
1089 return;
1090 case "task_started":
1091 yield* offerRuntimeEvent({
1092 ...base,
1093 type: "task.started",
1094 payload: {
1095 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1096 description: message.description,
1097 ...(message.task_type ? { taskType: message.task_type } : {}),
1098 },
1099 });
1100 return;
1101 case "task_progress":
1102 yield* offerRuntimeEvent({
1103 ...base,
1104 type: "task.progress",
1105 payload: {
1106 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1107 description: message.description,
1108 ...(message.usage ? { usage: message.usage } : {}),
1109 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
1110 },
1111 });
1112 return;
1113 case "task_notification":
1114 yield* offerRuntimeEvent({
1115 ...base,
1116 type: "task.completed",
1117 payload: {
1118 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1119 status: message.status,
1120 ...(message.summary ? { summary: message.summary } : {}),
1121 ...(message.usage ? { usage: message.usage } : {}),
1122 },
1123 });
1124 return;
1125 case "files_persisted":
1126 yield* offerRuntimeEvent({
1127 ...base,
1128 type: "files.persisted",
1129 payload: {
1130 files: Array.isArray(message.files)
1131 ? message.files.map((file: { filename: string; file_id: string }) => ({
1132 filename: file.filename,
1133 fileId: file.file_id,
1134 }))
1135 : [],
1136 ...(Array.isArray(message.failed)
1137 ? {
1138 failed: message.failed.map((entry: { filename: string; error: string }) => ({
1139 filename: entry.filename,
1140 error: entry.error,
1141 })),
1142 }
1143 : {}),
1144 },
1145 });
1146 return;
1147 default:
1148 yield* emitRuntimeWarning(
1149 context,
1150 `Unhandled Claude system message subtype '${message.subtype}'.`,
1151 message,
1152 );
1153 return;
1154 }
1155 });
1156
1157 const handleSdkTelemetryMessage = (
1158 context: ClaudeSessionContext,
1159 message: SDKMessage,
1160 ): Effect.Effect<void> =>
1161 Effect.gen(function* () {
1162 const stamp = yield* makeEventStamp();
1163 const base = {
1164 eventId: stamp.eventId,
1165 provider: PROVIDER,
1166 createdAt: stamp.createdAt,
1167 threadId: context.session.threadId,
1168 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1169 providerRefs: {
1170 ...providerThreadRef(context),
1171 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1172 },
1173 raw: {
1174 source: "claude.sdk.message" as const,
1175 method: sdkNativeMethod(message),
1176 messageType: message.type,
1177 payload: message,
1178 },
1179 };
1180
1181 if (message.type === "tool_progress") {
1182 yield* offerRuntimeEvent({
1183 ...base,
1184 type: "tool.progress",
1185 payload: {
1186 toolUseId: message.tool_use_id,
1187 toolName: message.tool_name,
1188 elapsedSeconds: message.elapsed_time_seconds,
1189 ...(message.task_id ? { summary: `task:${message.task_id}` } : {}),
1190 },
1191 });
1192 return;
1193 }
1194
1195 if (message.type === "tool_use_summary") {
1196 yield* offerRuntimeEvent({
1197 ...base,
1198 type: "tool.summary",
1199 payload: {
1200 summary: message.summary,
1201 ...(message.preceding_tool_use_ids.length > 0
1202 ? { precedingToolUseIds: message.preceding_tool_use_ids }
1203 : {}),
1204 },
1205 });
1206 return;
1207 }
1208
1209 if (message.type === "auth_status") {
1210 yield* offerRuntimeEvent({
1211 ...base,
1212 type: "auth.status",
1213 payload: {
1214 isAuthenticating: message.isAuthenticating,
1215 output: message.output,
1216 ...(message.error ? { error: message.error } : {}),
1217 },
1218 });
1219 return;
1220 }
1221
1222 if (message.type === "rate_limit_event") {
1223 yield* offerRuntimeEvent({
1224 ...base,
1225 type: "account.rate-limits.updated",
1226 payload: {
1227 rateLimits: message,
1228 },
1229 });
1230 return;
1231 }
1232 });
1233
1234 const handleSdkMessage = (
1235 context: ClaudeSessionContext,
1236 message: SDKMessage,
1237 ): Effect.Effect<void> =>
1238 Effect.gen(function* () {
1239 yield* logNativeSdkMessage(context, message);
1240 yield* ensureThreadId(context, message);
1241
1242 switch (message.type) {
1243 case "stream_event":
1244 yield* handleStreamEvent(context, message);
1245 return;
1246 case "user":
1247 return;
1248 case "assistant":
1249 yield* handleAssistantMessage(context, message);
1250 return;
1251 case "result":
1252 yield* handleResultMessage(context, message);
1253 return;
1254 case "system":
1255 yield* handleSystemMessage(context, message);
1256 return;
1257 case "tool_progress":
1258 case "tool_use_summary":
1259 case "auth_status":
1260 case "rate_limit_event":
1261 yield* handleSdkTelemetryMessage(context, message);
1262 return;
1263 default:
1264 yield* emitRuntimeWarning(
1265 context,
1266 `Unhandled Claude SDK message type '${message.type}'.`,
1267 message,
1268 );
1269 return;
1270 }
1271 });
1272
1273 const runSdkStream = (context: ClaudeSessionContext): Effect.Effect<void> =>
1274 Stream.fromAsyncIterable(context.query, (cause) => cause).pipe(
1275 Stream.takeWhile(() => !context.stopped),
1276 Stream.runForEach((message) => handleSdkMessage(context, message)),
1277 Effect.catchCause((cause) =>
1278 Effect.gen(function* () {
1279 if (Cause.hasInterruptsOnly(cause) || context.stopped) {
1280 return;
1281 }
1282 const message = toMessage(Cause.squash(cause), "Claude runtime stream failed.");
1283 yield* emitRuntimeError(context, message, cause);
1284 yield* completeTurn(context, "failed", message);
1285 }),
1286 ),
1287 );
1288
1289 const stopSessionInternal = (
1290 context: ClaudeSessionContext,
1291 options?: { readonly emitExitEvent?: boolean },
1292 ): Effect.Effect<void> =>
1293 Effect.gen(function* () {
1294 if (context.stopped) return;
1295
1296 context.stopped = true;
1297
1298 for (const [requestId, pending] of context.pendingApprovals) {
1299 yield* Deferred.succeed(pending.decision, "cancel");
1300 const stamp = yield* makeEventStamp();
1301 yield* offerRuntimeEvent({
1302 type: "request.resolved",
1303 eventId: stamp.eventId,
1304 provider: PROVIDER,
1305 createdAt: stamp.createdAt,
1306 threadId: context.session.threadId,
1307 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1308 requestId: asRuntimeRequestId(requestId),
1309 payload: {
1310 requestType: pending.requestType,
1311 decision: "cancel",
1312 },
1313 providerRefs: {
1314 ...providerThreadRef(context),
1315 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1316 providerRequestId: requestId,
1317 },
1318 });
1319 }
1320 context.pendingApprovals.clear();
1321
1322 if (context.turnState) {
1323 yield* completeTurn(context, "interrupted", "Session stopped.");
1324 }
1325
1326 yield* Queue.shutdown(context.promptQueue);
1327
1328 context.query.close();
1329
1330 const updatedAt = yield* nowIso;
1331 context.session = {
1332 ...context.session,
1333 status: "closed",
1334 activeTurnId: undefined,
1335 updatedAt,
1336 };
1337
1338 if (options?.emitExitEvent !== false) {
1339 const stamp = yield* makeEventStamp();
1340 yield* offerRuntimeEvent({
1341 type: "session.exited",
1342 eventId: stamp.eventId,
1343 provider: PROVIDER,
1344 createdAt: stamp.createdAt,
1345 threadId: context.session.threadId,
1346 payload: {
1347 reason: "Session stopped",
1348 exitKind: "graceful",
1349 },
1350 providerRefs: {},
1351 });
1352 }
1353
1354 sessions.delete(context.session.threadId);
1355 });
1356
1357 const requireSession = (
1358 threadId: ThreadId,
1359 ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => {
1360 const context = sessions.get(threadId);
1361 if (!context) {
1362 return Effect.fail(
1363 new ProviderAdapterSessionNotFoundError({
1364 provider: PROVIDER,
1365 threadId,
1366 }),
1367 );
1368 }
1369 if (context.stopped || context.session.status === "closed") {
1370 return Effect.fail(
1371 new ProviderAdapterSessionClosedError({
1372 provider: PROVIDER,
1373 threadId,
1374 }),
1375 );
1376 }
1377 return Effect.succeed(context);
1378 };
1379
1380 const startSession: ClaudeCodeAdapterShape["startSession"] = (input) =>
1381 Effect.gen(function* () {
1382 if (input.provider !== undefined && input.provider !== PROVIDER) {
1383 return yield* new ProviderAdapterValidationError({
1384 provider: PROVIDER,
1385 operation: "startSession",
1386 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
1387 });
1388 }
1389
1390 const startedAt = yield* nowIso;
1391 const resumeState = readClaudeResumeState(input.resumeCursor);
1392 const threadId = input.threadId;
1393
1394 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
1395 const prompt = Stream.fromQueue(promptQueue).pipe(
1396 Stream.filter((item) => item.type === "message"),
1397 Stream.map((item) => item.message),
1398 Stream.toAsyncIterable,
1399 );
1400
1401 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
1402 const inFlightTools = new Map<number, ToolInFlight>();
1403
1404 const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined);
1405
1406 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
1407 Effect.runPromise(
1408 Effect.gen(function* () {
1409 const context = yield* Ref.get(contextRef);
1410 if (!context) {
1411 return {
1412 behavior: "deny",
1413 message: "Claude session context is unavailable.",
1414 } satisfies PermissionResult;
1415 }
1416
1417 const runtimeMode = input.runtimeMode ?? "full-access";
1418 if (runtimeMode === "full-access") {
1419 return {
1420 behavior: "allow",
1421 updatedInput: toolInput,
1422 } satisfies PermissionResult;
1423 }
1424
1425 const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4);
1426 const requestType = classifyRequestType(toolName);
1427 const detail = summarizeToolRequest(toolName, toolInput);
1428 const decisionDeferred = yield* Deferred.make<ProviderApprovalDecision>();
1429 const pendingApproval: PendingApproval = {
1430 requestType,
1431 detail,
1432 decision: decisionDeferred,
1433 ...(callbackOptions.suggestions
1434 ? { suggestions: callbackOptions.suggestions }
1435 : {}),
1436 };
1437
1438 const requestedStamp = yield* makeEventStamp();
1439 yield* offerRuntimeEvent({
1440 type: "request.opened",
1441 eventId: requestedStamp.eventId,
1442 provider: PROVIDER,
1443 createdAt: requestedStamp.createdAt,
1444 threadId: context.session.threadId,
1445 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1446 requestId: asRuntimeRequestId(requestId),
1447 payload: {
1448 requestType,
1449 detail,
1450 args: {
1451 toolName,
1452 input: toolInput,
1453 ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}),
1454 },
1455 },
1456 providerRefs: {
1457 ...(context.session.threadId
1458 ? { providerThreadId: context.session.threadId }
1459 : {}),
1460 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1461 providerRequestId: requestId,
1462 },
1463 raw: {
1464 source: "claude.sdk.permission",
1465 method: "canUseTool/request",
1466 payload: {
1467 toolName,
1468 input: toolInput,
1469 },
1470 },
1471 });
1472
1473 pendingApprovals.set(requestId, pendingApproval);
1474
1475 const onAbort = () => {
1476 if (!pendingApprovals.has(requestId)) {
1477 return;
1478 }
1479 pendingApprovals.delete(requestId);
1480 Effect.runFork(Deferred.succeed(decisionDeferred, "cancel"));
1481 };
1482
1483 callbackOptions.signal.addEventListener("abort", onAbort, {
1484 once: true,
1485 });
1486
1487 const decision = yield* Deferred.await(decisionDeferred);
1488 pendingApprovals.delete(requestId);
1489
1490 const resolvedStamp = yield* makeEventStamp();
1491 yield* offerRuntimeEvent({
1492 type: "request.resolved",
1493 eventId: resolvedStamp.eventId,
1494 provider: PROVIDER,
1495 createdAt: resolvedStamp.createdAt,
1496 threadId: context.session.threadId,
1497 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1498 requestId: asRuntimeRequestId(requestId),
1499 payload: {
1500 requestType,
1501 decision,
1502 },
1503 providerRefs: {
1504 ...(context.session.threadId
1505 ? { providerThreadId: context.session.threadId }
1506 : {}),
1507 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1508 providerRequestId: requestId,
1509 },
1510 raw: {
1511 source: "claude.sdk.permission",
1512 method: "canUseTool/decision",
1513 payload: {
1514 decision,
1515 },
1516 },
1517 });
1518
1519 if (decision === "accept" || decision === "acceptForSession") {
1520 return {
1521 behavior: "allow",
1522 updatedInput: toolInput,
1523 ...(decision === "acceptForSession" && pendingApproval.suggestions
1524 ? { updatedPermissions: [...pendingApproval.suggestions] }
1525 : {}),
1526 } satisfies PermissionResult;
1527 }
1528
1529 return {
1530 behavior: "deny",
1531 message:
1532 decision === "cancel"
1533 ? "User cancelled tool execution."
1534 : "User declined tool execution.",
1535 } satisfies PermissionResult;
1536 }),
1537 );
1538
1539 const providerOptions = input.providerOptions?.claudeCode;
1540 const permissionMode =
1541 toPermissionMode(providerOptions?.permissionMode) ??
1542 (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined);
1543
1544 const queryOptions: ClaudeQueryOptions = {
1545 ...(input.cwd ? { cwd: input.cwd } : {}),
1546 ...(input.model ? { model: input.model } : {}),
1547 ...(providerOptions?.binaryPath
1548 ? { pathToClaudeCodeExecutable: providerOptions.binaryPath }
1549 : {}),
1550 ...(permissionMode ? { permissionMode } : {}),
1551 ...(permissionMode === "bypassPermissions"
1552 ? { allowDangerouslySkipPermissions: true }
1553 : {}),
1554 ...(providerOptions?.maxThinkingTokens !== undefined
1555 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
1556 : {}),
1557 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
1558 ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}),
1559 includePartialMessages: true,
1560 canUseTool,
1561 env: process.env,
1562 ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
1563 };
1564
1565 const queryRuntime = yield* Effect.try({
1566 try: () =>
1567 createQuery({
1568 prompt,
1569 options: queryOptions,
1570 }),
1571 catch: (cause) =>
1572 new ProviderAdapterProcessError({
1573 provider: PROVIDER,
1574 threadId,
1575 detail: toMessage(cause, "Failed to start Claude runtime session."),
1576 cause,
1577 }),
1578 });
1579
1580 const session: ProviderSession = {
1581 threadId,
1582 provider: PROVIDER,
1583 status: "ready",
1584 runtimeMode: input.runtimeMode,
1585 ...(input.cwd ? { cwd: input.cwd } : {}),
1586 ...(input.model ? { model: input.model } : {}),
1587 ...(threadId ? { threadId } : {}),
1588 resumeCursor: {
1589 ...(threadId ? { threadId } : {}),
1590 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
1591 ...(resumeState?.resumeSessionAt
1592 ? { resumeSessionAt: resumeState.resumeSessionAt }
1593 : {}),
1594 turnCount: resumeState?.turnCount ?? 0,
1595 },
1596 createdAt: startedAt,
1597 updatedAt: startedAt,
1598 };
1599
1600 const context: ClaudeSessionContext = {
1601 session,
1602 promptQueue,
1603 query: queryRuntime,
1604 startedAt,
1605 resumeSessionId: resumeState?.resume,
1606 pendingApprovals,
1607 turns: [],
1608 inFlightTools,
1609 turnState: undefined,
1610 lastAssistantUuid: resumeState?.resumeSessionAt,
1611 lastThreadStartedId: undefined,
1612 stopped: false,
1613 };
1614 yield* Ref.set(contextRef, context);
1615 sessions.set(threadId, context);
1616
1617 const sessionStartedStamp = yield* makeEventStamp();
1618 yield* offerRuntimeEvent({
1619 type: "session.started",
1620 eventId: sessionStartedStamp.eventId,
1621 provider: PROVIDER,
1622 createdAt: sessionStartedStamp.createdAt,
1623 threadId,
1624 payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {},
1625 providerRefs: {},
1626 });
1627
1628 const configuredStamp = yield* makeEventStamp();
1629 yield* offerRuntimeEvent({
1630 type: "session.configured",
1631 eventId: configuredStamp.eventId,
1632 provider: PROVIDER,
1633 createdAt: configuredStamp.createdAt,
1634 threadId,
1635 payload: {
1636 config: {
1637 ...(input.model ? { model: input.model } : {}),
1638 ...(input.cwd ? { cwd: input.cwd } : {}),
1639 ...(permissionMode ? { permissionMode } : {}),
1640 ...(providerOptions?.maxThinkingTokens !== undefined
1641 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
1642 : {}),
1643 },
1644 },
1645 providerRefs: {},
1646 });
1647
1648 const readyStamp = yield* makeEventStamp();
1649 yield* offerRuntimeEvent({
1650 type: "session.state.changed",
1651 eventId: readyStamp.eventId,
1652 provider: PROVIDER,
1653 createdAt: readyStamp.createdAt,
1654 threadId,
1655 payload: {
1656 state: "ready",
1657 },
1658 providerRefs: {},
1659 });
1660
1661 Effect.runFork(runSdkStream(context));
1662
1663 return {
1664 ...session,
1665 };
1666 });
1667
1668 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
1669 Effect.gen(function* () {
1670 const context = yield* requireSession(input.threadId);
1671
1672 if (context.turnState) {
1673 return yield* new ProviderAdapterValidationError({
1674 provider: PROVIDER,
1675 operation: "sendTurn",
1676 issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`,
1677 });
1678 }
1679
1680 if (input.model) {
1681 yield* Effect.tryPromise({
1682 try: () => context.query.setModel(input.model),
1683 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
1684 });
1685 }
1686
1687 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
1688 const turnState: ClaudeTurnState = {
1689 turnId,
1690 assistantItemId: yield* Random.nextUUIDv4,
1691 startedAt: yield* nowIso,
1692 items: [],
1693 messageCompleted: false,
1694 emittedTextDelta: false,
1695 fallbackAssistantText: "",
1696 };
1697
1698 const updatedAt = yield* nowIso;
1699 context.turnState = turnState;
1700 context.session = {
1701 ...context.session,
1702 status: "running",
1703 activeTurnId: turnId,
1704 updatedAt,
1705 };
1706
1707 const turnStartedStamp = yield* makeEventStamp();
1708 yield* offerRuntimeEvent({
1709 type: "turn.started",
1710 eventId: turnStartedStamp.eventId,
1711 provider: PROVIDER,
1712 createdAt: turnStartedStamp.createdAt,
1713 threadId: context.session.threadId,
1714 turnId,
1715 payload: input.model ? { model: input.model } : {},
1716 providerRefs: {
1717 providerTurnId: String(turnId),
1718 },
1719 });
1720
1721 const message = buildUserMessage(input);
1722
1723 yield* Queue.offer(context.promptQueue, {
1724 type: "message",
1725 message,
1726 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
1727
1728 return {
1729 threadId: context.session.threadId,
1730 turnId,
1731 ...(context.session.resumeCursor !== undefined
1732 ? { resumeCursor: context.session.resumeCursor }
1733 : {}),
1734 };
1735 });
1736
1737 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) =>
1738 Effect.gen(function* () {
1739 const context = yield* requireSession(threadId);
1740 yield* Effect.tryPromise({
1741 try: () => context.query.interrupt(),
1742 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
1743 });
1744 });
1745
1746 const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) =>
1747 Effect.gen(function* () {
1748 const context = yield* requireSession(threadId);
1749 return yield* snapshotThread(context);
1750 });
1751
1752 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) =>
1753 Effect.gen(function* () {
1754 const context = yield* requireSession(threadId);
1755 const nextLength = Math.max(0, context.turns.length - numTurns);
1756 context.turns.splice(nextLength);
1757 yield* updateResumeCursor(context);
1758 return yield* snapshotThread(context);
1759 });
1760
1761 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
1762 threadId,
1763 requestId,
1764 decision,
1765 ) =>
1766 Effect.gen(function* () {
1767 const context = yield* requireSession(threadId);
1768 const pending = context.pendingApprovals.get(requestId);
1769 if (!pending) {
1770 return yield* new ProviderAdapterRequestError({
1771 provider: PROVIDER,
1772 method: "item/requestApproval/decision",
1773 detail: `Unknown pending approval request: ${requestId}`,
1774 });
1775 }
1776
1777 context.pendingApprovals.delete(requestId);
1778 yield* Deferred.succeed(pending.decision, decision);
1779 });
1780
1781 const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = (
1782 threadId,
1783 requestId,
1784 _answers,
1785 ) =>
1786 Effect.fail(
1787 new ProviderAdapterRequestError({
1788 provider: PROVIDER,
1789 method: "item/tool/requestUserInput",
1790 detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`,
1791 }),
1792 );
1793
1794 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
1795 Effect.gen(function* () {
1796 const context = yield* requireSession(threadId);
1797 yield* stopSessionInternal(context, {
1798 emitExitEvent: true,
1799 });
1800 });
1801
1802 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>
1803 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
1804
1805 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) =>
1806 Effect.sync(() => {
1807 const context = sessions.get(threadId);
1808 return context !== undefined && !context.stopped;
1809 });
1810
1811 const stopAll: ClaudeCodeAdapterShape["stopAll"] = () =>
1812 Effect.forEach(
1813 sessions,
1814 ([, context]) =>
1815 stopSessionInternal(context, {
1816 emitExitEvent: true,
1817 }),
1818 { discard: true },
1819 );
1820
1821 yield* Effect.addFinalizer(() =>
1822 Effect.forEach(
1823 sessions,
1824 ([, context]) =>
1825 stopSessionInternal(context, {
1826 emitExitEvent: false,
1827 }),
1828 { discard: true },
1829 ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))),
1830 );
1831
1832 return {
1833 provider: PROVIDER,
1834 capabilities: {
1835 sessionModelSwitch: "in-session",
1836 },
1837 startSession,
1838 sendTurn,
1839 interruptTurn,
1840 readThread,
1841 rollbackThread,
1842 respondToRequest,
1843 respondToUserInput,
1844 stopSession,
1845 listSessions,
1846 hasSession,
1847 stopAll,
1848 streamEvents: Stream.fromQueue(runtimeEventQueue),
1849 } satisfies ClaudeCodeAdapterShape;
1850 });
1851}
1852
1853export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter());
1854
1855export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) {
1856 return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options));
1857}
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+2-25
......@@ -4,7 +4,6 @@ import { assertFailure } from "@effect/vitest/utils";
44
55import { Effect, Layer, Stream } from "effect";
66
7import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
87import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
98import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
109import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
......@@ -28,31 +27,11 @@ const fakeCodexAdapter: CodexAdapterShape = {
2827 streamEvents: Stream.empty,
2928};
3029
31const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
32 provider: "claudeCode",
33 capabilities: { sessionModelSwitch: "in-session" },
34 startSession: vi.fn(),
35 sendTurn: vi.fn(),
36 interruptTurn: vi.fn(),
37 respondToRequest: vi.fn(),
38 respondToUserInput: vi.fn(),
39 stopSession: vi.fn(),
40 listSessions: vi.fn(),
41 hasSession: vi.fn(),
42 readThread: vi.fn(),
43 rollbackThread: vi.fn(),
44 stopAll: vi.fn(),
45 streamEvents: Stream.empty,
46};
47
4830const layer = it.layer(
4931 Layer.mergeAll(
5032 Layer.provide(
5133 ProviderAdapterRegistryLive,
52 Layer.mergeAll(
53 Layer.succeed(CodexAdapter, fakeCodexAdapter),
54 Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter),
55 ),
34 Layer.succeed(CodexAdapter, fakeCodexAdapter),
5635 ),
5736 NodeServices.layer,
5837 ),
......@@ -63,12 +42,10 @@ layer("ProviderAdapterRegistryLive", (it) => {
6342 Effect.gen(function* () {
6443 const registry = yield* ProviderAdapterRegistry;
6544 const codex = yield* registry.getByProvider("codex");
66 const claude = yield* registry.getByProvider("claudeCode");
6745 assert.equal(codex, fakeCodexAdapter);
68 assert.equal(claude, fakeClaudeAdapter);
6946
7047 const providers = yield* registry.listProviders();
71 assert.deepEqual(providers, ["codex", "claudeCode"]);
48 assert.deepEqual(providers, ["codex"]);
7249 }),
7350 );
7451
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-2
......@@ -15,7 +15,6 @@ import {
1515 ProviderAdapterRegistry,
1616 type ProviderAdapterRegistryShape,
1717} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";
1918import { CodexAdapter } from "../Services/CodexAdapter.ts";
2019
2120export interface ProviderAdapterRegistryLiveOptions {
......@@ -27,7 +26,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption
2726 const adapters =
2827 options?.adapters !== undefined
2928 ? options.adapters
30 : [yield* CodexAdapter, yield* ClaudeCodeAdapter];
29 : [yield* CodexAdapter];
3130 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3231
3332 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Services/ClaudeCodeAdapter.ts deleted-32
......@@ -1,32 +0,0 @@
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 */
13import { ServiceMap } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
17
18/**
19 * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter.
20 */
21export interface ClaudeCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "claudeCode";
23}
24
25/**
26 * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations.
27 */
28export class ClaudeCodeAdapter extends ServiceMap.Service<
29 ClaudeCodeAdapter,
30 ClaudeCodeAdapterShape
31>()("t3/provider/Services/ClaudeCodeAdapter") {}
32
apps/server/src/serverLayers.ts-5
......@@ -18,7 +18,6 @@ import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/Proj
1818import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
1919import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
2020import { ProviderUnsupportedError } from "./provider/Errors";
21import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter";
2221import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
2322import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
2423import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
......@@ -57,12 +56,8 @@ export function makeServerProviderLayer(): Layer.Layer<
5756 const codexAdapterLayer = makeCodexAdapterLive(
5857 nativeEventLogger ? { nativeEventLogger } : undefined,
5958 );
60 const claudeAdapterLayer = makeClaudeCodeAdapterLive(
61 nativeEventLogger ? { nativeEventLogger } : undefined,
62 );
6359 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
6460 Layer.provide(codexAdapterLayer),
65 Layer.provide(claudeAdapterLayer),
6661 Layer.provideMerge(providerSessionDirectoryLayer),
6762 );
6863 return makeProviderServiceLive(
apps/web/src/routes/_chat.settings.tsx-7
......@@ -47,13 +47,6 @@ const MODEL_PROVIDER_SETTINGS: Array<{
4747 placeholder: "your-codex-model-slug",
4848 example: "gpt-6.7-codex-ultra-preview",
4949 },
50 {
51 provider: "claudeCode",
52 title: "Claude Code",
53 description: "Save additional Claude model slugs for the picker and `/model` command.",
54 placeholder: "your-claude-model-slug",
55 example: "claude-sonnet-5-0",
56 },
5750] as const;
5851
5952function getCustomModelsForProvider(
apps/web/src/session-logic.test.ts+3-9
......@@ -514,17 +514,11 @@ describe("isLatestTurnSettled", () => {
514514});
515515
516516describe("PROVIDER_OPTIONS", () => {
517 it("marks claudeCode as available", () => {
517 it("only advertises codex in the stack base", () => {
518518 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
519 expect(claude).toEqual({
520 value: "claudeCode",
521 label: "Claude Code",
522 available: true,
523 });
524 });
525
526 it("does not advertise cursor as an available provider", () => {
527519 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
520 expect(PROVIDER_OPTIONS).toEqual([{ value: "codex", label: "Codex", available: true }]);
521 expect(claude).toBeUndefined();
528522 expect(cursor).toBeUndefined();
529523 });
530524});
apps/web/src/session-logic.ts-1
......@@ -16,7 +16,6 @@ export const PROVIDER_OPTIONS: Array<{
1616 available: boolean;
1717}> = [
1818 { value: "codex", label: "Codex", available: true },
19 { value: "claudeCode", label: "Claude Code", available: true },
2019];
2120
2221export interface WorkLogEntry {