From a614b3c3305436793fd1949430e34eb1f6da39c4 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:06:33 +0100 Subject: [PATCH 01/10] Add Claude Code support on current main --- apps/server/package.json | 1 + .../Layers/ProviderCommandReactor.test.ts | 60 +- .../Layers/ProviderCommandReactor.ts | 4 +- .../Layers/ProviderRuntimeIngestion.test.ts | 77 +- .../Layers/ProviderRuntimeIngestion.ts | 100 + .../provider/Layers/ClaudeCodeAdapter.test.ts | 931 ++++++++ .../src/provider/Layers/ClaudeCodeAdapter.ts | 1863 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 30 +- .../Layers/ProviderAdapterRegistry.ts | 6 +- .../Layers/ProviderSessionDirectory.test.ts | 22 + .../Layers/ProviderSessionDirectory.ts | 2 +- .../provider/Services/ClaudeCodeAdapter.ts | 31 + apps/server/src/serverLayers.ts | 5 + apps/web/src/appSettings.test.ts | 17 + apps/web/src/appSettings.ts | 4 + apps/web/src/components/ChatView.logic.ts | 18 + apps/web/src/components/ChatView.tsx | 22 +- .../components/chat/ProviderModelPicker.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 92 + apps/web/src/composerDraftStore.ts | 96 +- apps/web/src/hooks/useHandleNewThread.ts | 9 +- apps/web/src/routes/_chat.settings.tsx | 14 + apps/web/src/session-logic.test.ts | 6 +- apps/web/src/session-logic.ts | 4 +- apps/web/src/store.test.ts | 4 +- apps/web/src/store.ts | 13 +- bun.lock | 3 + packages/contracts/src/model.ts | 30 +- packages/contracts/src/orchestration.ts | 9 +- packages/contracts/src/provider.test.ts | 26 + packages/contracts/src/provider.ts | 8 + packages/contracts/src/providerRuntime.ts | 2 + packages/shared/src/model.test.ts | 5 + packages/shared/src/model.ts | 1 + 34 files changed, 3487 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeCodeAdapter.ts create mode 100644 apps/server/src/provider/Services/ClaudeCodeAdapter.ts diff --git a/apps/server/package.json b/apps/server/package.json index a6ffd53b8..7783cacc0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "test": "vitest run" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f..0b919e90d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -96,7 +96,7 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || input.provider === "claudeCode") ? input.provider : "codex"; const resumeCursor = @@ -435,6 +435,64 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("restarts existing claude threads on the claude provider when a session restart is needed", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-existing"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-existing"), + role: "user", + text: "restart with claude", + attachments: [], + }, + model: "claude-sonnet-4-6", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + runtimeMode: "full-access", + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..49e1ca917 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -206,7 +206,9 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..d59353229 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -566,6 +566,81 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("upserts claude reasoning deltas into a single thinking activity", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-thinking"), + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-thinking-delta-1"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-thinking"), + itemId: asItemId("item-thinking"), + payload: { + streamKind: "reasoning_text", + delta: "Inspecting the provider state.", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-thinking-delta-2"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-thinking"), + itemId: asItemId("item-thinking"), + payload: { + streamKind: "reasoning_summary_text", + delta: " Verifying restart routing.", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "thinking:thread-1:turn:turn-thinking" && + typeof (activity.payload as { detail?: unknown } | null)?.detail === "string" && + (activity.payload as { detail: string }).detail.includes("Verifying restart routing."), + ), + ); + const reasoningActivities = thread.activities.filter( + (activity: ProviderRuntimeTestActivity) => + activity.id === "thinking:thread-1:turn:turn-thinking", + ); + + expect(reasoningActivities).toHaveLength(1); + expect(reasoningActivities[0]).toMatchObject({ + tone: "thinking", + kind: "reasoning.trace", + summary: "Thinking", + turnId: "turn-thinking", + payload: { + detail: "Inspecting the provider state. Verifying restart routing.", + streamKind: "reasoning_summary_text", + }, + }); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d..a65a73a53 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type AssistantDeliveryMode, CommandId, + EventId, MessageId, type OrchestrationEvent, CheckpointRef, @@ -34,6 +35,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); +const BUFFERED_REASONING_BY_ID_CACHE_CAPACITY = 10_000; +const BUFFERED_REASONING_BY_ID_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -94,6 +97,17 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } +function reasoningActivityIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId): EventId { + const turnId = toTurnId(event.turnId); + if (turnId) { + return EventId.makeUnsafe(`thinking:${threadId}:turn:${turnId}`); + } + if (event.itemId) { + return EventId.makeUnsafe(`thinking:${threadId}:item:${event.itemId}`); + } + return EventId.makeUnsafe(`thinking:${threadId}:event:${event.eventId}`); +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -503,6 +517,12 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const bufferedReasoningById = yield* Cache.make({ + capacity: BUFFERED_REASONING_BY_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_REASONING_BY_ID_TTL, + lookup: () => Effect.succeed({ text: "", createdAt: "" }), + }); + const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); @@ -620,6 +640,56 @@ const make = Effect.gen(function* () { const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); + const appendBufferedReasoning = (activityId: EventId, delta: string, createdAt: string) => + Cache.getOption(bufferedReasoningById, activityId).pipe( + Effect.flatMap((existingEntry) => { + const existing = Option.getOrUndefined(existingEntry); + return Cache.set(bufferedReasoningById, activityId, { + text: `${existing?.text ?? ""}${delta}`, + createdAt: + existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, + }); + }), + ); + + const upsertReasoningActivity = (input: { + readonly event: ProviderRuntimeEvent; + readonly threadId: ThreadId; + readonly activityId: EventId; + readonly turnId?: TurnId; + readonly streamKind: "reasoning_text" | "reasoning_summary_text"; + readonly updatedAt: string; + }) => + Effect.gen(function* () { + const bufferedReasoning = yield* Cache.getOption( + bufferedReasoningById, + input.activityId, + ).pipe(Effect.map(Option.getOrUndefined)); + const detail = bufferedReasoning?.text.trim(); + if (!detail) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: providerCommandId(input.event, "reasoning-activity-upsert"), + threadId: input.threadId, + activity: { + id: input.activityId, + tone: "thinking", + kind: "reasoning.trace", + summary: "Thinking", + payload: { + detail, + streamKind: input.streamKind, + }, + turnId: input.turnId ?? null, + createdAt: bufferedReasoning?.createdAt ?? input.updatedAt, + }, + createdAt: input.updatedAt, + }); + }); + const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); @@ -741,8 +811,10 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const prefix = `${threadId}:`; const proposedPlanPrefix = `plan:${threadId}:`; + const reasoningPrefix = `thinking:${threadId}:`; const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + const reasoningKeys = Array.from(yield* Cache.keys(bufferedReasoningById)); yield* Effect.forEach( turnKeys, (key) => @@ -770,6 +842,14 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + reasoningKeys, + (key) => + key.startsWith(reasoningPrefix) + ? Cache.invalidate(bufferedReasoningById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const processRuntimeEvent = (event: ProviderRuntimeEvent) => @@ -920,6 +1000,26 @@ const make = Effect.gen(function* () { yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); } + if ( + event.type === "content.delta" && + (event.payload.streamKind === "reasoning_text" || + event.payload.streamKind === "reasoning_summary_text") && + event.payload.delta.length > 0 + ) { + const activityId = reasoningActivityIdFromEvent(event, thread.id); + const reasoningTurnId = toTurnId(event.turnId); + const reasoningStreamKind = event.payload.streamKind; + yield* appendBufferedReasoning(activityId, event.payload.delta, now); + yield* upsertReasoningActivity({ + event, + threadId: thread.id, + activityId, + ...(reasoningTurnId ? { turnId: reasoningTurnId } : {}), + streamKind: reasoningStreamKind, + updatedAt: now, + }); + } + const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" ? { diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts new file mode 100644 index 000000000..c5b720e6c --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -0,0 +1,931 @@ +import type { + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Random, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { + makeClaudeCodeAdapterLive, + type ClaudeCodeAdapterLiveOptions, +} from "./ClaudeCodeAdapter.ts"; + +class FakeClaudeQuery implements AsyncIterable { + private readonly queue: Array = []; + private readonly resolvers: Array<(value: IteratorResult) => void> = []; + private done = false; + + public readonly interruptCalls: Array = []; + public readonly setModelCalls: Array = []; + public readonly setPermissionModeCalls: Array = []; + public readonly setMaxThinkingTokensCalls: Array = []; + public closeCalls = 0; + + emit(message: SDKMessage): void { + if (this.done) { + return; + } + const resolver = this.resolvers.shift(); + if (resolver) { + resolver({ done: false, value: message }); + return; + } + this.queue.push(message); + } + + finish(): void { + if (this.done) { + return; + } + this.done = true; + for (const resolver of this.resolvers.splice(0)) { + resolver({ done: true, value: undefined }); + } + } + + readonly interrupt = async (): Promise => { + this.interruptCalls.push(undefined); + }; + + readonly setModel = async (model?: string): Promise => { + this.setModelCalls.push(model); + }; + + readonly setPermissionMode = async (mode: PermissionMode): Promise => { + this.setPermissionModeCalls.push(mode); + }; + + readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise => { + this.setMaxThinkingTokensCalls.push(maxThinkingTokens); + }; + + readonly close = (): void => { + this.closeCalls += 1; + this.finish(); + }; + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.queue.length > 0) { + const value = this.queue.shift(); + if (value) { + return Promise.resolve({ + done: false, + value, + }); + } + } + if (this.done) { + return Promise.resolve({ + done: true, + value: undefined, + }); + } + return new Promise((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +interface Harness { + readonly layer: ReturnType; + readonly query: FakeClaudeQuery; + readonly getLastCreateQueryInput: () => + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; +} + +function makeHarness(config?: { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"]; +}): Harness { + const query = new FakeClaudeQuery(); + let createInput: + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; + + const adapterOptions: ClaudeCodeAdapterLiveOptions = { + createQuery: (input) => { + createInput = input; + return query; + }, + ...(config?.nativeEventLogger + ? { + nativeEventLogger: config.nativeEventLogger, + } + : {}), + ...(config?.nativeEventLogPath + ? { + nativeEventLogPath: config.nativeEventLogPath, + } + : {}), + }; + + return { + layer: makeClaudeCodeAdapterLive(adapterOptions), + query, + getLastCreateQueryInput: () => createInput, + }; +} + +function makeDeterministicRandomService(seed = 0x1234_5678): { + nextIntUnsafe: () => number; + nextDoubleUnsafe: () => number; +} { + let state = seed >>> 0; + const nextIntUnsafe = (): number => { + state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0; + return state; + }; + + return { + nextIntUnsafe, + nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000, + }; +} + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); + +describe("ClaudeCodeAdapterLive", () => { + it.effect("returns validation error for non-claudeCode provider on startSession", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const result = yield* adapter + .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeCode", + operation: "startSession", + issue: "Expected provider 'claudeCode' but received 'codex'.", + }), + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("derives bypass permission mode from full-access runtime policy", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + permissionMode: "plan", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "plan"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + model: "claude-sonnet-4-5", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-1", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Hi", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-2", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-1", + name: "Bash", + input: { + command: "ls", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-3", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "content.delta", + "item.started", + "item.completed", + "item.updated", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = runtimeEvents[3]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(String(turnStarted.turnId), String(turn.turnId)); + } + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Hi"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); + } + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "emits completion only after turn result when assistant frames arrive before deltas", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-early-assistant", + uuid: "assistant-early", + parent_tool_use_id: null, + message: { + id: "assistant-message-early", + content: [ + { type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-early-assistant", + uuid: "stream-early", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Late text", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-early-assistant", + uuid: "result-early", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta"); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed"); + assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); + + const deltaEvent = runtimeEvents[deltaIndex]; + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Late text"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("falls back to assistant payload text when stream deltas are absent", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-fallback-text", + uuid: "assistant-fallback", + parent_tool_use_id: null, + message: { + id: "assistant-message-fallback", + content: [{ type: "text", text: "Fallback hello" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-fallback-text", + uuid: "result-fallback", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Fallback hello"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not fabricate provider thread ids before first SDK session_id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + assert.equal(session.threadId, THREAD_ID); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + assert.equal(turn.threadId, THREAD_ID); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-thread-real", + uuid: "stream-thread-real", + parent_tool_use_id: null, + event: { + type: "message_start", + message: { + id: "msg-thread-real", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-thread-real", + uuid: "result-thread-real", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + ], + ); + + const sessionStarted = runtimeEvents[0]; + assert.equal(sessionStarted?.type, "session.started"); + if (sessionStarted?.type === "session.started") { + assert.equal(sessionStarted.threadId, THREAD_ID); + } + + const threadStarted = runtimeEvents[4]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.equal(threadStarted.threadId, THREAD_ID); + assert.equal(threadStarted.payload.providerThreadId, "sdk-thread-real"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("bridges approval request/response lifecycle through canUseTool", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "pwd" }, + { + signal: new AbortController().signal, + suggestions: [ + { + type: "setMode", + mode: "default", + destination: "session", + }, + ], + toolUseID: "tool-use-1", + }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some") { + return; + } + assert.equal(requested.value.type, "request.opened"); + if (requested.value.type !== "request.opened") { + return; + } + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(runtimeRequestId), + "accept", + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + assert.equal(resolved.value.requestId, requested.value.requestId); + assert.equal(resolved.value.payload.decision, "accept"); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("passes parsed resume cursor values to Claude query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: "claudeCode", + resumeCursor: { + threadId: "resume-thread-1", + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + assert.equal(session.threadId, RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + threadId: String(RESUME_THREAD_ID), + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000"); + assert.equal(createInput?.options.resumeSessionAt, "assistant-99"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not synthesize resume session id from generated thread ids", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + assert.equal("resume" in (session.resumeCursor as Record), false); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "supports rollbackThread by trimming in-memory turns and preserving earlier turns", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first", + attachments: [], + }); + + const firstCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-first", + } as unknown as SDKMessage); + + const firstCompleted = yield* Fiber.join(firstCompletedFiber); + assert.equal(firstCompleted._tag, "Some"); + if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { + assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + } + + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "second", + attachments: [], + }); + + const secondCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-second", + } as unknown as SDKMessage); + + const secondCompleted = yield* Fiber.join(secondCompletedFiber); + assert.equal(secondCompleted._tag, "Some"); + if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { + assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); + } + + const threadBeforeRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadBeforeRollback.turns.length, 2); + + const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); + assert.equal(rolledBack.turns.length, 1); + assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + + const threadAfterRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadAfterRollback.turns.length, 1); + assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("updates model on sendTurn when model override is provided", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + model: "claude-opus-4-6", + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("writes provider-native observability records when enabled", () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + turnId?: string; + }; + }> = []; + const harness = makeHarness({ + nativeEventLogger: { + filePath: "memory://claude-native-events", + write: (event) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-native-log", + uuid: "stream-native-log", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "hi", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-native-log", + uuid: "result-native-log", + } as unknown as SDKMessage); + + const turnCompleted = yield* Fiber.join(turnCompletedFiber); + assert.equal(turnCompleted._tag, "Some"); + + assert.equal(nativeEvents.length > 0, true); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "claudeCode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta", + ), + true, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..c4bc93a9f --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,1863 @@ +/** + * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * + * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic + * provider adapter contract and emits canonical runtime events. + * + * @module ClaudeCodeAdapterLive + */ +import { + type CanUseTool, + query, + type Options as ClaudeQueryOptions, + type PermissionMode, + type PermissionResult, + type PermissionUpdate, + type SDKMessage, + type SDKResultMessage, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "claudeCode" as const; + +type PromptQueueItem = + | { + readonly type: "message"; + readonly message: SDKUserMessage; + } + | { + readonly type: "terminate"; + }; + +interface ClaudeResumeState { + readonly threadId?: ThreadId; + readonly resume?: string; + readonly resumeSessionAt?: string; + readonly turnCount?: number; +} + +interface ClaudeTurnState { + readonly turnId: TurnId; + readonly assistantItemId: string; + readonly startedAt: string; + readonly items: Array; + readonly messageCompleted: boolean; + readonly emittedTextDelta: boolean; + readonly fallbackAssistantText: string; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly suggestions?: ReadonlyArray; + readonly decision: Deferred.Deferred; +} + +interface ToolInFlight { + readonly itemId: string; + readonly itemType: CanonicalItemType; + readonly toolName: string; + readonly title: string; + readonly detail?: string; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly promptQueue: Queue.Queue; + readonly query: ClaudeQueryRuntime; + readonly startedAt: string; + resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + readonly inFlightTools: Map; + turnState: ClaudeTurnState | undefined; + lastAssistantUuid: string | undefined; + lastThreadStartedId: string | undefined; + stopped: boolean; +} + +interface ClaudeQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; + readonly setModel: (model?: string) => Promise; + readonly setPermissionMode: (mode: PermissionMode) => Promise; + readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly close: () => void; +} + +export interface ClaudeCodeAdapterLiveOptions { + readonly createQuery?: (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => ClaudeQueryRuntime; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function isUuid(value: string): boolean { + 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); +} + +function isSyntheticClaudeThreadId(value: string): boolean { + return value.startsWith("claude-thread-"); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + +function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const cursor = resumeCursor as { + threadId?: unknown; + resume?: unknown; + sessionId?: unknown; + resumeSessionAt?: unknown; + turnCount?: unknown; + }; + + const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; + const threadId = + threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) + ? ThreadId.makeUnsafe(threadIdCandidate) + : undefined; + const resumeCandidate = + typeof cursor.resume === "string" + ? cursor.resume + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; + const resumeSessionAt = + typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; + const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + + return { + ...(threadId ? { threadId } : {}), + ...(resume ? { resume } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 + ? { turnCount: turnCountValue } + : {}), + }; +} + +function classifyToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; +} + +function classifyRequestType(toolName: string): CanonicalRequestType { + const normalized = toolName.toLowerCase(); + if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + return "file_read_approval"; + } + return classifyToolItemType(toolName) === "command_execution" + ? "command_execution_approval" + : "file_change_approval"; +} + +function summarizeToolRequest(toolName: string, input: Record): string { + const commandValue = input.command ?? input.cmd; + const command = typeof commandValue === "string" ? commandValue : undefined; + if (command && command.trim().length > 0) { + return `${toolName}: ${command.trim().slice(0, 400)}`; + } + + const serialized = JSON.stringify(input); + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { + const fragments: string[] = []; + + if (input.input && input.input.trim().length > 0) { + fragments.push(input.input.trim()); + } + + for (const attachment of input.attachments ?? []) { + if (attachment.type === "image") { + fragments.push( + `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, + ); + } + } + + const text = fragments.join("\n\n"); + + return { + type: "user", + session_id: "", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text }], + }, + } as SDKUserMessage; +} + +function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if (result.subtype === "success") { + return "completed"; + } + + const errors = result.errors.join(" ").toLowerCase(); + if (errors.includes("interrupt")) { + return "interrupted"; + } + if (errors.includes("cancel")) { + return "cancelled"; + } + return "failed"; +} + +function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + +function providerThreadRef( + context: ClaudeSessionContext, +): { readonly providerThreadId: string } | {} { + return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +} + +function extractAssistantText(message: SDKMessage): string { + if (message.type !== "assistant") { + return ""; + } + + const content = (message.message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) { + return ""; + } + + const fragments: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const candidate = block as { type?: unknown; text?: unknown }; + if ( + candidate.type === "text" && + typeof candidate.text === "string" && + candidate.text.length > 0 + ) { + fragments.push(candidate.text); + } + } + + return fragments.join(""); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function sdkMessageType(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { type?: unknown }; + return typeof record.type === "string" ? record.type : undefined; +} + +function sdkMessageSubtype(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { subtype?: unknown }; + return typeof record.subtype === "string" ? record.subtype : undefined; +} + +function sdkNativeMethod(message: SDKMessage): string { + const subtype = sdkMessageSubtype(message); + if (subtype) { + return `claude/${message.type}/${subtype}`; + } + + if (message.type === "stream_event") { + const streamType = sdkMessageType(message.event); + if (streamType) { + const deltaType = + streamType === "content_block_delta" + ? sdkMessageType((message.event as { delta?: unknown }).delta) + : undefined; + if (deltaType) { + return `claude/${message.type}/${streamType}/${deltaType}`; + } + return `claude/${message.type}/${streamType}`; + } + } + + return `claude/${message.type}`; +} + +function sdkNativeItemId(message: SDKMessage): string | undefined { + if (message.type === "assistant") { + const maybeId = (message.message as { id?: unknown }).id; + if (typeof maybeId === "string") { + return maybeId; + } + return undefined; + } + + if (message.type === "stream_event") { + const event = message.event as { + type?: unknown; + content_block?: { id?: unknown }; + }; + if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { + return event.content_block.id; + } + } + + return undefined; +} + +function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const createQuery = + options?.createQuery ?? + ((input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const logNativeSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } + + const observedAt = new Date().toISOString(); + const itemId = sdkNativeItemId(message); + + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: + "uuid" in message && typeof message.uuid === "string" + ? message.uuid + : crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method: sdkNativeMethod(message), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + payload: message, + }, + }, + null, + ); + }); + + const snapshotThread = ( + context: ClaudeSessionContext, + ): Effect.Effect< + { + threadId: ThreadId; + turns: ReadonlyArray<{ + id: TurnId; + items: ReadonlyArray; + }>; + }, + ProviderAdapterValidationError + > => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "readThread", + issue: "Session thread id is not initialized yet.", + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) return; + + const resumeCursor = { + threadId, + ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), + ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + turnCount: context.turns.length, + }; + + context.session = { + ...context.session, + resumeCursor, + updatedAt: yield* nowIso, + }; + }); + + const ensureThreadId = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (typeof message.session_id !== "string" || message.session_id.length === 0) { + return; + } + const nextThreadId = message.session_id; + context.resumeSessionId = message.session_id; + yield* updateResumeCursor(context); + + if (context.lastThreadStartedId !== nextThreadId) { + context.lastThreadStartedId = nextThreadId; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: {}, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, + }); + } + }); + + const emitRuntimeError = ( + context: ClaudeSessionContext, + message: string, + cause?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + if (cause !== undefined) { + void cause; + } + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const completeTurn = ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + errorMessage?: string, + result?: SDKResultMessage, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: {}, + }); + return; + } + + if (!turnState.messageCompleted) { + if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: turnState.fallbackAssistantText, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: String(turnState.turnId), + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(turnState.assistantItemId), + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + }, + }); + + const updatedAt = yield* nowIso; + context.turnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + yield* updateResumeCursor(context); + }); + + const handleStreamEvent = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "stream_event") { + return; + } + + const { event } = message; + + if (event.type === "content_block_delta") { + if ( + event.delta.type === "text_delta" && + event.delta.text.length > 0 && + context.turnState + ) { + if (!context.turnState.emittedTextDelta) { + context.turnState = { + ...context.turnState, + emittedTextDelta: true, + }; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: streamKindFromDeltaType(event.delta.type), + delta: event.delta.text, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { + return; + } + + const toolName = block.name; + const itemType = classifyToolItemType(toolName); + const toolInput = + typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; + const itemId = block.id; + const detail = summarizeToolRequest(toolName, toolInput); + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + }; + context.inFlightTools.set(index, tool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + + if (event.type === "content_block_stop") { + const { index } = event; + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + context.inFlightTools.delete(index); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "completed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_stop", + payload: message, + }, + }); + } + }); + + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } + + if (context.turnState) { + context.turnState.items.push(message.message); + const fallbackAssistantText = extractAssistantText(message); + if ( + fallbackAssistantText.length > 0 && + fallbackAssistantText !== context.turnState.fallbackAssistantText + ) { + context.turnState = { + ...context.turnState, + fallbackAssistantText, + }; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + data: message.message, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/assistant", + payload: message, + }, + }); + } + + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + }); + + const handleResultMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "result") { + return; + } + + const status = turnStatusFromResult(message); + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + + if (status === "failed") { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } + + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, + }, + }; + + switch (message.subtype) { + case "init": + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + payload: { + config: message as Record, + }, + }); + return; + case "status": + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, + }, + }); + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + }, + }); + return; + case "task_progress": + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + }, + }); + return; + case "task_notification": + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + }, + }); + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${message.subtype}'.`, + message, + ); + return; + } + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, + }, + }; + + if (message.type === "tool_progress") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + }, + }); + return; + } + + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } + + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } + + if (message.type === "rate_limit_event") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } + }); + + const handleSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + yield* logNativeSdkMessage(context, message); + yield* ensureThreadId(context, message); + + switch (message.type) { + case "stream_event": + yield* handleStreamEvent(context, message); + return; + case "user": + return; + case "assistant": + yield* handleAssistantMessage(context, message); + return; + case "result": + yield* handleResultMessage(context, message); + return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit_event": + yield* handleSdkTelemetryMessage(context, message); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${message.type}'.`, + message, + ); + return; + } + }); + + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( + Stream.takeWhile(() => !context.stopped), + Stream.runForEach((message) => handleSdkMessage(context, message)), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause) || context.stopped) { + return; + } + const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); + yield* emitRuntimeError(context, message, cause); + yield* completeTurn(context, "failed", message); + }), + ), + ); + + const stopSessionInternal = ( + context: ClaudeSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) return; + + context.stopped = true; + + for (const [requestId, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + }); + } + context.pendingApprovals.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped."); + } + + yield* Queue.shutdown(context.promptQueue); + + context.query.close(); + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: {}, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const resumeState = readClaudeResumeState(input.resumeCursor); + const threadId = input.threadId; + + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.filter((item) => item.type === "message"), + Stream.map((item) => item.message), + Stream.toAsyncIterable, + ); + + const pendingApprovals = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => + Effect.runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + if (!context) { + return { + behavior: "deny", + message: "Claude session context is unavailable.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState + ? { providerTurnId: String(context.turnState.turnId) } + : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState + ? { providerTurnId: String(context.turnState.turnId) } + : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); + + const providerOptions = input.providerOptions?.claudeCode; + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(providerOptions?.binaryPath + ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } + : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + includePartialMessages: true, + canUseTool, + env: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); + + const session: ProviderSession = { + threadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + startedAt, + resumeSessionId: resumeState?.resume, + pendingApprovals, + turns: [], + inFlightTools, + turnState: undefined, + lastAssistantUuid: resumeState?.resumeSessionAt, + lastThreadStartedId: undefined, + stopped: false, + }; + yield* Ref.set(contextRef, context); + sessions.set(threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, + }); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + createdAt: configuredStamp.createdAt, + threadId, + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + }, + }, + providerRefs: {}, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", + }, + providerRefs: {}, + }); + + Effect.runFork(runSdkStream(context)); + + return { + ...session, + }; + }); + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, + }); + } + + if (input.model) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(input.model), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + assistantItemId: yield* Random.nextUUIDv4, + startedAt: yield* nowIso, + items: [], + messageCompleted: false, + emittedTextDelta: false, + fallbackAssistantText: "", + }; + + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const message = buildUserMessage(input); + + yield* Queue.offer(context.promptQueue, { + type: "message", + message, + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + }); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); + }); + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/requestApproval/decision", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/requestUserInput", + detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeCodeAdapterShape; + }); +} + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08..7b6da886e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -27,9 +28,32 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeAdapter: ClaudeCodeAdapterShape = { + provider: "claudeCode", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide( + ProviderAdapterRegistryLive, + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + ), + ), NodeServices.layer, ), ); @@ -39,10 +63,12 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const claude = yield* registry.getByProvider("claudeCode"); assert.equal(codex, fakeCodexAdapter); + assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeCode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed790..61fa2d18c 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,6 +15,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -23,7 +24,10 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => Effect.gen(function* () { - const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const adapters = + options?.adapters !== undefined + ? options.adapters + : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0..d524d8f4f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -204,4 +204,26 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("accepts claude provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.makeUnsafe("thread-claude"); + + yield* directory.upsert({ + provider: "claudeCode", + threadId, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "claudeCode"); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "claudeCode", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 38e097e1c..71b6d3c42 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeCode") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..6ef687606 --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts @@ -0,0 +1,31 @@ +/** + * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract. + * + * This service owns Claude runtime/session semantics and emits canonical + * provider runtime events. It does not perform cross-provider routing, shared + * event fan-out, or checkpoint orchestration. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "claudeCode"` context. + * + * @module ClaudeCodeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter. + */ +export interface ClaudeCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "claudeCode"; +} + +/** + * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations. + */ +export class ClaudeCodeAdapter extends ServiceMap.Service< + ClaudeCodeAdapter, + ClaudeCodeAdapterShape +>()("t3/provider/Services/ClaudeCodeAdapter") {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..dea77b9fc 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..c96b8b24a 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -45,6 +45,17 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("supports Claude built-ins and saved custom models", () => { + const options = getAppModelOptions("claudeCode", ["claude-internal-preview"]); + + expect(options.map((option) => option.slug)).toEqual([ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-internal-preview", + ]); + }); }); describe("resolveAppModelSelection", () => { @@ -57,6 +68,12 @@ describe("resolveAppModelSelection", () => { it("falls back to the provider default when no model is selected", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); + + it("preserves Claude custom model slugs instead of falling back", () => { + expect( + resolveAppModelSelection("claudeCode", ["claude-sonnet-internal"], "claude-sonnet-internal"), + ).toBe("claude-sonnet-internal"); + }); }); describe("timestamp format defaults", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..928d88799 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -12,6 +12,7 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -34,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..2bd3af151 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -118,8 +118,26 @@ export function cloneComposerImageForRetry( export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), }; } + +export function getCustomModelSlugsForProvider( + settings: { + customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + }, + provider: ProviderKind, +): readonly string[] { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "codex": + default: + return settings.customCodexModels; + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e..89eef4e6b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,7 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + getCustomModelSlugsForProvider, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -230,6 +231,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const copyComposerDraftSettings = useComposerDraftStore((store) => store.copyThreadSettings); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); @@ -437,6 +439,7 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); + copyComposerDraftSettings(threadId, nextThreadId); await navigate({ to: "/$threadId", params: { threadId: nextThreadId }, @@ -445,6 +448,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [ activeProject, clearProjectDraftThreadId, + copyComposerDraftSettings, getDraftThread, getDraftThreadByProjectId, isServerThread, @@ -500,7 +504,10 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = getCustomModelSlugsForProvider( + settings, + selectedProvider, + ); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -528,6 +535,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); const providerOptionsForDispatch = useMemo(() => { + if (selectedProvider !== "codex") { + return undefined; + } if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } @@ -537,7 +547,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; - }, [settings.codexBinaryPath, settings.codexHomePath]); + }, [selectedProvider, settings.codexBinaryPath, settings.codexHomePath]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2882,7 +2892,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection( + provider, + getCustomModelSlugsForProvider(settings, provider), + model, + ), ); scheduleComposerFocus(); }, @@ -2892,7 +2906,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, + settings, ], ); const onEffortSelect = useCallback( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9bc034991..abec3419d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -25,7 +25,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } function resolveModelForProviderPicker( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..a9815a9f1 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2,8 +2,10 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + clearPromotedDraftThreads, type ComposerImageAttachment, createDebouncedStorage, + normalizePersistedComposerDraftState, useComposerDraftStore, } from "./composerDraftStore"; @@ -405,6 +407,22 @@ describe("composerDraftStore setProvider", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex"); }); + it("keeps claude provider selections when normalizing persisted drafts", () => { + const normalized = normalizePersistedComposerDraftState({ + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + provider: "claudeCode", + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + + expect(normalized.draftsByThreadId[threadId]?.provider).toBe("claudeCode"); + }); + it("removes empty provider-only draft when provider is reset", () => { const store = useComposerDraftStore.getState(); @@ -415,6 +433,47 @@ describe("composerDraftStore setProvider", () => { }); }); +describe("composerDraftStore copyThreadSettings", () => { + const sourceThreadId = ThreadId.makeUnsafe("thread-source"); + const targetThreadId = ThreadId.makeUnsafe("thread-target"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("copies non-content chat selections into a fresh draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProvider(sourceThreadId, "claudeCode"); + store.setModel(sourceThreadId, "claude-sonnet-4-6"); + store.setRuntimeMode(sourceThreadId, "approval-required"); + store.setInteractionMode(sourceThreadId, "plan"); + + store.copyThreadSettings(sourceThreadId, targetThreadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[targetThreadId]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + runtimeMode: "approval-required", + interactionMode: "plan", + }); + expect(useComposerDraftStore.getState().draftsByThreadId[targetThreadId]?.prompt).toBe(""); + expect(useComposerDraftStore.getState().draftsByThreadId[targetThreadId]?.images).toEqual([]); + }); + + it("ignores source drafts that only contain prompt content", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(sourceThreadId, "do not copy me"); + + store.copyThreadSettings(sourceThreadId, targetThreadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[targetThreadId]).toBeUndefined(); + }); +}); + describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.makeUnsafe("thread-settings"); @@ -458,6 +517,39 @@ describe("composerDraftStore runtime and interaction settings", () => { }); }); +describe("clearPromotedDraftThreads", () => { + const projectId = ProjectId.makeUnsafe("project-promoted"); + const threadId = ThreadId.makeUnsafe("thread-promoted"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("preserves chat-window selections after a draft thread is promoted", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setProvider(threadId, "claudeCode"); + store.setModel(threadId, "claude-sonnet-4-6"); + store.setRuntimeMode(threadId, "approval-required"); + store.setInteractionMode(threadId, "plan"); + + clearPromotedDraftThreads(new Set([threadId])); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + runtimeMode: "approval-required", + interactionMode: "plan", + }); + }); +}); + // --------------------------------------------------------------------------- // createDebouncedStorage // --------------------------------------------------------------------------- diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..21d85e21b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -158,6 +158,7 @@ interface ComposerDraftStoreState { ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; + clearDraftThreadRegistration: (threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; @@ -177,6 +178,7 @@ interface ComposerDraftStoreState { threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; + copyThreadSettings: (sourceThreadId: ThreadId, targetThreadId: ThreadId) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; } @@ -245,8 +247,22 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { ); } +function hasNonContentDraftSettings(draft: ComposerThreadDraftState | undefined): boolean { + if (!draft) { + return false; + } + return ( + draft.provider !== null || + draft.model !== null || + draft.runtimeMode !== null || + draft.interactionMode !== null || + draft.effort !== null || + draft.codexFastMode + ); +} + function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "claudeCode" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -300,7 +316,9 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } -function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { +export function normalizePersistedComposerDraftState( + value: unknown, +): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } @@ -773,6 +791,31 @@ export const useComposerDraftStore = create()( }; }); }, + clearDraftThreadRegistration: (threadId) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; + const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + threadId, + ); + if (!hasDraftThread && !hasProjectMapping) { + return state; + } + const nextProjectDraftThreadIdByProjectId = Object.fromEntries( + Object.entries(state.projectDraftThreadIdByProjectId).filter( + ([, draftThreadId]) => draftThreadId !== threadId, + ), + ) as Record; + const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = + state.draftThreadsByThreadId; + return { + draftThreadsByThreadId: restDraftThreadsByThreadId, + projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + }; + }); + }, clearDraftThread: (threadId) => { if (threadId.length === 0) { return; @@ -1144,6 +1187,48 @@ export const useComposerDraftStore = create()( }); }); }, + copyThreadSettings: (sourceThreadId, targetThreadId) => { + if (sourceThreadId.length === 0 || targetThreadId.length === 0) { + return; + } + if (sourceThreadId === targetThreadId) { + return; + } + set((state) => { + const source = state.draftsByThreadId[sourceThreadId]; + if (!source || !hasNonContentDraftSettings(source)) { + return state; + } + const existingTarget = state.draftsByThreadId[targetThreadId]; + const target = existingTarget ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...target, + provider: source.provider, + model: source.model, + runtimeMode: source.runtimeMode, + interactionMode: source.interactionMode, + effort: source.effort, + codexFastMode: source.codexFastMode, + }; + const isUnchanged = + existingTarget && + existingTarget.provider === nextDraft.provider && + existingTarget.model === nextDraft.model && + existingTarget.runtimeMode === nextDraft.runtimeMode && + existingTarget.interactionMode === nextDraft.interactionMode && + existingTarget.effort === nextDraft.effort && + existingTarget.codexFastMode === nextDraft.codexFastMode; + if (isUnchanged) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [targetThreadId]: nextDraft, + }, + }; + }); + }, clearComposerContent: (threadId) => { if (threadId.length === 0) { return; @@ -1284,15 +1369,16 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS * Clear draft threads that have been promoted to server threads. * * Call this after a snapshot sync so the route guard in `_chat.$threadId` - * sees the server thread before the draft is removed — avoids a redirect - * to `/` caused by a gap where neither draft nor server thread exists. + * sees the server thread before the draft registration is removed. Preserve + * non-content composer selections so provider/model choices continue to stick + * after the first local-draft send promotes the thread. */ export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet): void { const store = useComposerDraftStore.getState(); const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[]; for (const draftId of draftThreadIds) { if (serverThreadIds.has(draftId)) { - store.clearDraftThread(draftId); + store.clearDraftThreadRegistration(draftId); } } } diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 35f92d98e..e711e98b8 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,6 +20,9 @@ export function useHandleNewThread() { const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); + const activeComposerDraft = useComposerDraftStore((store) => + routeThreadId ? (store.draftsByThreadId[routeThreadId] ?? null) : null, + ); const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) @@ -38,6 +41,7 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, + copyThreadSettings, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -96,6 +100,9 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (routeThreadId && activeComposerDraft) { + copyThreadSettings(routeThreadId, threadId); + } await navigate({ to: "/$threadId", @@ -103,7 +110,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId], + [activeComposerDraft, navigate, routeThreadId], ); return { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..62e9ed8ce 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, ] as const; const TIMESTAMP_FORMAT_LABELS = { @@ -67,6 +74,8 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return settings.customClaudeModels; case "codex": default: return settings.customCodexModels; @@ -78,6 +87,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return defaults.customClaudeModels; case "codex": default: return defaults.customCodexModels; @@ -86,6 +97,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "claudeCode": + return { customClaudeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -102,6 +115,7 @@ function SettingsRouteView() { Record >({ codex: "", + claudeCode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814..2882e30be 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -673,18 +673,18 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { + it("keeps Claude Code selectable while Cursor remains a placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ value: "claudeCode", label: "Claude Code", - available: false, + available: true, }); expect(cursor).toEqual({ value: "cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..d18660d1c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,7 +18,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -26,7 +26,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..a6e7c3d4e 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -191,7 +191,7 @@ describe("store pure functions", () => { }); describe("store read model sync", () => { - it("falls back to the codex default for unsupported provider models without an active session", () => { + it("infers Claude from Claude model slugs even without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ @@ -201,7 +201,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); }); it("preserves the current project order when syncing incoming read model updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..4c6f43a2b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -189,26 +189,33 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeCode") { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CLAUDE_MODEL_SLUGS = new Set( + getModelOptions("claudeCode").map((option) => option.slug), +); function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode") { return input.sessionProviderName; } + const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); + if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { + return "claudeCode"; + } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; } - return "codex"; + return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex"; } function resolveWsHttpOrigin(): string { diff --git a/bun.lock b/bun.lock index b8e36149f..3659f86cc 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", @@ -182,6 +183,8 @@ "vitest": "^4.0.0", }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.76", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw=="], + "@astrojs/check": ["@astrojs/check@0.9.7", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-dA7U5/OFg8/xaMUb2vUOOJuuJXnMpHy6F0BM8ZhL7WT5OkTBwJ0GoW38n4fC4CXt+lT9mLWL0y8Pa74tFByBpQ=="], "@astrojs/compiler": ["@astrojs/compiler@3.0.0", "", {}, "sha512-MwAbDE5mawZ1SS+D8qWiHdprdME5Tlj2e0YjxnEICvcOpbSukNS7Sa7hA5PK+6RrmUr/t6Gi5YgrdZKjbO/WPQ=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..0a6b6fa72 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { ProviderKind } from "./orchestration"; +import type { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; @@ -10,8 +10,14 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ClaudeCodeModelOptions = Schema.Struct({ + thinking: Schema.optional(Schema.Boolean), +}); +export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + claudeCode: Schema.optional(ClaudeCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +34,11 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claudeCode: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +47,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + claudeCode: "claude-sonnet-4-6", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +58,28 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + claudeCode: { + opus: "claude-opus-4-6", + "opus-4.6": "claude-opus-4-6", + "claude-opus-4.6": "claude-opus-4-6", + "claude-opus-4-6-20251117": "claude-opus-4-6", + sonnet: "claude-sonnet-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", + haiku: "claude-haiku-4-5", + "haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4-5-20251001": "claude-haiku-4-5", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeCode: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + claudeCode: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..2fb3cec3e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -47,8 +47,14 @@ const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), }); +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + permissionMode: Schema.optional(TrimmedNonEmptyString), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), }); export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; @@ -216,6 +222,7 @@ export const OrchestrationThreadActivityTone = Schema.Literals([ "info", "tool", "approval", + "thinking", "error", ]); export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b7..9acb75aa6 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -34,6 +34,32 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("accepts claude-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + thinking: true, + }, + }, + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 4000, + }, + }, + }); + + expect(parsed.modelOptions?.claudeCode?.thinking).toBe(true); + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); + expect(parsed.providerOptions?.claudeCode?.permissionMode).toBe("plan"); + expect(parsed.providerOptions?.claudeCode?.maxThinkingTokens).toBe(4000); + }); + it("rejects payloads without runtime mode", () => { expect(() => decodeProviderSessionStartInput({ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9d2a198b6..8a61f0a15 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -5,6 +5,7 @@ import { ApprovalRequestId, EventId, IsoDateTime, + NonNegativeInt, ProviderItemId, ThreadId, TurnId, @@ -52,8 +53,15 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + permissionMode: Schema.optional(TrimmedNonEmptyStringSchema), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index d76475ab5..cc5d267ef 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -20,6 +20,8 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.request", "codex.eventmsg", "codex.sdk.thread-event", + "claude.sdk.message", + "claude.sdk.permission", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1..bd0251027 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,6 +14,7 @@ describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("sonnet", "claudeCode")).toBe("claude-sonnet-4-6"); }); it("returns null for empty or missing values", () => { @@ -49,6 +50,9 @@ describe("resolveModelSlug", () => { for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { expect(resolveModelSlug(model.slug)).toBe(model.slug); } + for (const model of MODEL_OPTIONS_BY_PROVIDER.claudeCode) { + expect(resolveModelSlug(model.slug, "claudeCode")).toBe(model.slug); + } }); it("keeps codex defaults for backward compatibility", () => { expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); @@ -65,5 +69,6 @@ describe("getReasoningEffortOptions", () => { describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("claudeCode")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..ec86e7601 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { From ac250970c87d68769e95ddf2b256a2b5d7e4b0c0 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:31:09 +0100 Subject: [PATCH 02/10] Complete Claude support refresh coverage --- .../OrchestrationEngineHarness.integration.ts | 2 +- .../TestProviderAdapter.integration.ts | 10 +- .../orchestrationEngine.integration.test.ts | 410 +++++++++++++++++- .../Layers/CheckpointReactor.test.ts | 135 +++++- .../provider/Layers/ProviderHealth.test.ts | 156 +++++++ .../src/provider/Layers/ProviderHealth.ts | 213 ++++++++- .../provider/Layers/ProviderService.test.ts | 75 +++- 7 files changed, 977 insertions(+), 24 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c5eb125ab..642f9b3de 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -205,7 +205,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; readonly realCodex?: boolean; } diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 017c59e2c..98ad7b8b5 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -35,7 +35,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -177,7 +177,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -197,7 +197,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; } function nowIso(): string { @@ -205,7 +205,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -215,7 +215,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 42dcfe34f..dd3227394 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -36,7 +36,7 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = "codex"; +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -137,6 +137,7 @@ const startTurn = (input: { readonly messageId: string; readonly text: string; readonly provider?: IntegrationProvider; + readonly model?: string; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -149,6 +150,7 @@ const startTurn = (input: { attachments: [], }, ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), @@ -846,6 +848,412 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), ); +it.live("starts a claudeCode session on first turn when provider is requested", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live("recovers claudeCode sessions after adapter stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.adapter.stopAll(); + yield* waitForSync( + () => harness.adapterHarness!.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "claude adapter stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + model: "claude-sonnet-4-6", + }); + + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread(THREAD_ID, (entry) => entry.session?.threadId === "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent( + (event) => event.type === "thread.turn-interrupt-requested", + ); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness!.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live("reverts claudeCode turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + model: "claude-sonnet-4-6", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeCode", + ), +); + it.live( "appends checkpoint.revert.failed activity when revert is requested without an active session", () => diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 09773b71d..1ae40a464 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -44,7 +44,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -235,6 +235,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; + readonly providerName?: "codex" | "claudeCode"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -242,7 +243,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - "codex", + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -403,6 +404,67 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); + it("captures pre-turn and completion checkpoints for claudeCode runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeCode", + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, + ); + + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + }); + it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -792,6 +854,75 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("executes provider revert and emits thread.reverted for claudeCode sessions", async () => { + const harness = await createHarness({ providerName: "claudeCode" }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 10bd12a7c..ce938d7de 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -5,11 +5,16 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + CLAUDE_CLI_PATH, + checkClaudeProviderStatus, checkCodexProviderStatus, hasCustomModelProvider, + parseClaudeAuthStatusFromOutput, parseAuthStatusFromOutput, + ProviderHealthLive, readCodexConfigModelProvider, } from "./ProviderHealth"; +import { ProviderHealth } from "../Services/ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -42,6 +47,22 @@ function mockSpawnerLayer( ); } +function mockSpawnerCommandLayer( + handler: (input: { command: string; args: ReadonlyArray }) => { + stdout: string; + stderr: string; + code: number; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler({ command: cmd.command, args: cmd.args }))); + }), + ); +} + function failingSpawnerLayer(description: string) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -235,6 +256,75 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude runtime is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerCommandLayer(({ command, args }) => { + assert.strictEqual(command, CLAUDE_CLI_PATH); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { + stdout: '{"authenticated":true,"email":"test@example.com"}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected Claude args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude runtime is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.message, "Claude Code runtime is not available."); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns unauthenticated when claude auth probe reports login required", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude Code is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerCommandLayer(({ command, args }) => { + assert.strictEqual(command, CLAUDE_CLI_PATH); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { stdout: "", stderr: "Not logged in. Run claude auth login.", code: 1 }; + } + throw new Error(`Unexpected Claude args: ${joined}`); + }), + ), + ), + ); + }); + // ── Custom model provider: checkCodexProviderStatus integration ─── describe("checkCodexProviderStatus with custom model provider", () => { @@ -341,6 +431,72 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); }); + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with authenticated=true is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("login required output is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: "", + stderr: "Not logged in. Run claude auth login.", + code: 1, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + }); + + describe("ProviderHealthLive", () => { + it.effect("publishes codex and claude provider statuses", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const providerHealth = yield* ProviderHealth; + const statuses = yield* providerHealth.getStatuses; + + assert.deepStrictEqual( + statuses.map((status) => status.provider), + ["codex", "claudeCode"], + ); + }).pipe( + Effect.provide( + ProviderHealthLive.pipe( + Layer.provide( + mockSpawnerCommandLayer(({ command, args }) => { + const joined = args.join(" "); + if (command === "codex") { + if (joined === "--version") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + } + + if (command === CLAUDE_CLI_PATH) { + if (joined === "--version") { + return { stdout: "2.1.76 (Claude Code)\n", stderr: "", code: 0 }; + } + if (joined === "auth status --json") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + } + + throw new Error(`Unexpected provider health command: ${command} ${joined}`); + }), + ), + ), + ), + ), + ); + }); + // ── readCodexConfigModelProvider tests ───────────────────────────── describe("readCodexConfigModelProvider", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1fed0597a..12a07b73b 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -9,6 +9,8 @@ * @module ProviderHealthLive */ import * as OS from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; import type { ServerProviderAuthStatus, ServerProviderStatus, @@ -26,6 +28,12 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const CLAUDE_PROVIDER = "claudeCode" as const; +const require = createRequire(import.meta.url); +export const CLAUDE_CLI_PATH = path.join( + path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), + "cli.js", +); // ── Pure helpers ──────────────────────────────────────────────────── @@ -41,12 +49,12 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function isCommandMissingCause(error: unknown): boolean { +function isCommandMissingCause(command: string, error: unknown): boolean { if (!(error instanceof Error)) return false; const lower = error.message.toLowerCase(); return ( - lower.includes("command not found: codex") || - lower.includes("spawn codex enoent") || + lower.includes(`command not found: ${command.toLowerCase()}`) || + lower.includes(`spawn ${command.toLowerCase()} enoent`) || lower.includes("enoent") || lower.includes("notfound") ); @@ -243,14 +251,18 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runCodexCommand = (args: ReadonlyArray) => runCommand("codex", args); + +const runClaudeCommand = (args: ReadonlyArray) => runCommand(CLAUDE_CLI_PATH, args); + +const runCommand = (command: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const childProcess = ChildProcess.make(command, [...args], { shell: process.platform === "win32", }); - const child = yield* spawner.spawn(command); + const child = yield* spawner.spawn(childProcess); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -287,7 +299,7 @@ export const checkCodexProviderStatus: Effect.Effect< available: false, authStatus: "unknown" as const, checkedAt, - message: isCommandMissingCause(error) + message: isCommandMissingCause("codex", error) ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }; @@ -390,18 +402,195 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export function parseClaudeAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: "Claude Code authentication status command is unavailable in this version.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("sign in") || + lowerOutput.includes("run `claude auth login`") || + lowerOutput.includes("run claude auth login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude Code authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude Code authentication status. ${detail}` + : "Could not verify Claude Code authentication status.", + }; +} + +export const checkClaudeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause("claude", error) + ? "Claude Code runtime is not available." + : `Failed to execute Claude Code health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + "Claude Code runtime is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CLAUDE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Claude Code runtime is installed but failed to run. ${detail}` + : "Claude Code runtime is installed but failed to run.", + }; + } + + const authProbe = yield* runClaudeCommand(["auth", "status", "--json"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude Code authentication status: ${error.message}.` + : "Could not verify Claude Code authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + "Could not verify Claude Code authentication status. Timed out while running command.", + }; + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatusFiber = yield* checkCodexProviderStatus.pipe( - Effect.map(Array.of), - Effect.forkScoped, - ); + const providerStatusesFiber = yield* Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus], + { concurrency: "unbounded" }, + ).pipe(Effect.forkScoped); return { - getStatuses: Fiber.join(codexStatusFiber), + getStatuses: Fiber.join(providerStatusesFiber), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d5cf4424b..e0edd2e48 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -52,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -217,12 +217,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeCode"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -247,6 +250,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -493,6 +497,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeCode"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const startInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { provider?: string; cwd?: string }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -573,10 +600,52 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("recovers stale claudeCode sessions for sendTurn using persisted cwd", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude-send-turn"), + cwd: "/tmp/project-claude-send-turn", + runtimeMode: "full-access", + }); + + yield* routing.claude.stopAll(); + routing.claude.startSession.mockClear(); + routing.claude.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume with claude", + attachments: [], + }); + + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const resumedStartInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + assert.equal(routing.claude.sendTurn.mock.calls.length, 1); + }), + ); + it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { const provider = yield* ProviderService; + yield* routing.claude.stopAll(); + yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", threadId: asThreadId("thread-1"), From 217ad4f8f2d448932ecb419d2e74f0f0a652a630 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:12:24 +0100 Subject: [PATCH 03/10] fix: refresh provider health status checks --- .../src/provider/Layers/ProviderHealth.ts | 18 ++++++++++++------ .../src/provider/Services/ProviderHealth.ts | 6 +++--- .../components/chat/ProviderHealthBanner.tsx | 15 ++++++++++----- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 12a07b73b..6b687603f 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -16,7 +16,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Array, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -584,13 +584,19 @@ export const checkClaudeProviderStatus: Effect.Effect< export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const providerStatusesFiber = yield* Effect.all( - [checkCodexProviderStatus, checkClaudeProviderStatus], - { concurrency: "unbounded" }, - ).pipe(Effect.forkScoped); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const healthRuntime = Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(FileSystem.FileSystem, fileSystem), + Layer.succeed(Path.Path, path), + ); return { - getStatuses: Fiber.join(providerStatusesFiber), + getStatuses: Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { + concurrency: "unbounded", + }).pipe(Effect.provide(healthRuntime)), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d..ec3b2d318 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -1,8 +1,8 @@ /** * ProviderHealth - Provider readiness snapshot service. * - * Owns startup-time provider health checks (install/auth reachability) and - * exposes the cached results to transport layers. + * Owns provider health checks (install/auth reachability) and exposes the + * latest results to transport layers. * * @module ProviderHealth */ @@ -12,7 +12,7 @@ import type { Effect } from "effect"; export interface ProviderHealthShape { /** - * Read provider health statuses computed at server startup. + * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; } diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 12c7f6054..08d71606e 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -12,18 +12,23 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ return null; } + const providerLabel = + status.provider === "codex" + ? "Codex" + : status.provider === "claudeCode" + ? "Claude Code" + : status.provider; const defaultMessage = status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; + ? `${providerLabel} provider is unavailable.` + : `${providerLabel} provider has limited availability.`; + const title = providerLabel === "Codex" ? "Codex provider status" : `${providerLabel} status`; return (
- - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} - + {title} {status.message ?? defaultMessage} From c47b792c6ed8ab4ed9f667bb06237d1cee2496f9 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:37:21 +0100 Subject: [PATCH 04/10] feat: add claude effort controls --- .../Layers/ProviderCommandReactor.test.ts | 117 +++ .../Layers/ProviderCommandReactor.ts | 42 +- .../provider/Layers/ClaudeCodeAdapter.test.ts | 81 ++ .../src/provider/Layers/ClaudeCodeAdapter.ts | 10 +- .../provider/Layers/ProviderService.test.ts | 11 + .../src/provider/Layers/ProviderService.ts | 19 +- apps/web/src/components/ChatView.tsx | 966 ++++++++++-------- .../src/components/chat/CodexTraitsPicker.tsx | 80 +- .../chat/CompactComposerControlsMenu.tsx | 56 +- apps/web/src/composerDraftStore.ts | 26 +- apps/web/src/index.css | 19 + packages/contracts/src/model.ts | 12 +- packages/contracts/src/provider.test.ts | 16 + packages/shared/src/model.test.ts | 54 +- packages/shared/src/model.ts | 78 +- 15 files changed, 1062 insertions(+), 525 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0b919e90d..5ddc65323 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -351,6 +351,56 @@ describe("ProviderCommandReactor", () => { }); }); + it("forwards claude effort options through session start and turn send", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort"), + role: "user", + text: "hello with effort", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -493,6 +543,73 @@ describe("ProviderCommandReactor", () => { }); }); + it("restarts claude sessions when claude effort changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-1"), + role: "user", + text: "first claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "medium", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-2"), + role: "user", + text: "second claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "claudeCode", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 49e1ca917..60fcaa3ba 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -74,6 +74,11 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const sameModelOptions = ( + left: ProviderModelOptions | undefined, + right: ProviderModelOptions | undefined, +): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -136,6 +141,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); + const threadModelOptions = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -269,13 +275,23 @@ const make = Effect.gen(function* () { : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; + const previousModelOptions = threadModelOptions.get(threadId); + const shouldRestartForModelOptionsChange = + currentProvider === "claudeCode" && + options?.modelOptions !== undefined && + !sameModelOptions(previousModelOptions, options.modelOptions); - if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + if ( + !runtimeModeChanged && + !providerChanged && + !shouldRestartForModelChange && + !shouldRestartForModelOptionsChange + ) { return existingSessionThreadId; } const resumeCursor = - providerChanged || shouldRestartForModelChange + providerChanged || shouldRestartForModelChange || shouldRestartForModelOptionsChange ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { @@ -289,6 +305,7 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, + shouldRestartForModelOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession({ @@ -328,15 +345,18 @@ const make = Effect.gen(function* () { if (!thread) { return; } - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); + if (input.providerOptions !== undefined) { + threadProviderOptions.set(input.threadId, input.providerOptions); + } + if (input.modelOptions !== undefined) { + threadModelOptions.set(input.threadId, input.modelOptions); + } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService @@ -626,13 +646,13 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - yield* ensureSessionForThread( - event.payload.threadId, - event.occurredAt, - cachedProviderOptions !== undefined + const cachedModelOptions = threadModelOptions.get(event.payload.threadId); + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { + ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } - : undefined, - ); + : {}), + ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), + }); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index c5b720e6c..4e76a32c2 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -159,6 +159,28 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { }; } +async function readFirstPromptText( + input: + | { + readonly prompt: AsyncIterable; + } + | undefined, +): Promise { + const iterator = input?.prompt[Symbol.asyncIterator](); + if (!iterator) { + return undefined; + } + const next = await iterator.next(); + if (next.done) { + return undefined; + } + const content = next.value.message.content[0]; + if (!content || content.type !== "text") { + return undefined; + } + return content.text; +} + const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); @@ -232,6 +254,65 @@ describe("ClaudeCodeAdapterLive", () => { ); }); + it.effect("forwards claude effort levels into query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps ultrathink to max effort and prefixes the prompt", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Investigate the edge cases", + attachments: [], + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); + assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index c4bc93a9f..54cc68c48 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -34,6 +34,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { applyClaudePromptEffortPrefix, getEffectiveClaudeCodeEffort } from "@t3tools/shared/model"; import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; import { @@ -284,7 +285,10 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { } } - const text = fragments.join("\n\n"); + const text = applyClaudePromptEffortPrefix( + fragments.join("\n\n"), + input.modelOptions?.claudeCode?.effort ?? null, + ); return { type: "user", @@ -1543,6 +1547,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeCode; + const effort = input.modelOptions?.claudeCode?.effort; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = toPermissionMode(providerOptions?.permissionMode) ?? (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); @@ -1553,6 +1559,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(providerOptions?.binaryPath ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } @@ -1642,6 +1649,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { config: { ...(input.model ? { model: input.model } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(providerOptions?.maxThinkingTokens !== undefined ? { maxThinkingTokens: providerOptions.maxThinkingTokens } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e0edd2e48..693895e32 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -608,6 +608,11 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: "claudeCode", threadId: asThreadId("thread-claude-send-turn"), cwd: "/tmp/project-claude-send-turn", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, runtimeMode: "full-access", }); @@ -628,11 +633,17 @@ routing.layer("ProviderServiceLive routing", (it) => { const startPayload = resumedStartInput as { provider?: string; cwd?: string; + modelOptions?: unknown; resumeCursor?: unknown; threadId?: string; }; assert.equal(startPayload.provider, "claudeCode"); assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.deepEqual(startPayload.modelOptions, { + claudeCode: { + effort: "max", + }, + }); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc7204..0a250f965 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -88,17 +88,29 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st function toRuntimePayloadFromSession( session: ProviderSession, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ): Record { return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), }; } +function readPersistedModelOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): Record | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + function readPersistedProviderOptions( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): Record | undefined { @@ -150,7 +162,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ) => directory.upsert({ threadId, @@ -213,12 +225,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -292,6 +306,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId, { + modelOptions: input.modelOptions, providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89eef4e6b..8d83be353 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,9 +1,9 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, + type ClaudeCodeEffort, type EditorId, type KeybindingCommand, - type CodexReasoningEffort, type MessageId, type ProjectId, type ProjectEntry, @@ -15,6 +15,7 @@ import { type ProviderApprovalDecision, type ServerProviderStatus, type ProviderKind, + type ProviderReasoningEffort, type ThreadId, type TurnId, OrchestrationThreadActivity, @@ -22,10 +23,12 @@ import { ProviderInteractionMode, } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, normalizeModelSlug, + resolveReasoningEffortForProvider, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -137,7 +140,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { ProviderTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; @@ -171,6 +174,17 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +function formatOutgoingPrompt(params: { + provider: ProviderKind; + effort: ProviderReasoningEffort | null; + text: string; +}): string { + if (params.provider !== "claudeCode") { + return params.text; + } + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); +} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -521,19 +535,45 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedCodexEffort = + selectedProvider === "codex" + ? (resolveReasoningEffortForProvider("codex", composerDraft.effort) ?? + getDefaultReasoningEffort("codex")) + : null; + const selectedClaudeEffort = + selectedProvider === "claudeCode" + ? (resolveReasoningEffortForProvider("claudeCode", composerDraft.effort) ?? + getDefaultReasoningEffort("claudeCode")) + : null; + const selectedEffort = selectedCodexEffort ?? selectedClaudeEffort; const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const isClaudeUltrathink = selectedClaudeEffort === "ultrathink"; const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider !== "codex") { - return undefined; + if (selectedProvider === "codex") { + const codexOptions = { + ...(supportsReasoningEffort && selectedCodexEffort + ? { reasoningEffort: selectedCodexEffort } + : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; } - const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + if (selectedProvider === "claudeCode" && selectedClaudeEffort) { + return { + claudeCode: { + effort: selectedClaudeEffort, + }, + }; + } + return undefined; + }, [ + selectedClaudeEffort, + selectedCodexEffort, + selectedCodexFastModeEnabled, + selectedProvider, + supportsReasoningEffort, + ]); const providerOptionsForDispatch = useMemo(() => { if (selectedProvider !== "codex") { return undefined; @@ -2269,6 +2309,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2291,7 +2336,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2429,7 +2474,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: outgoingMessageText, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2670,6 +2715,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed, + }); sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2679,7 +2729,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2707,7 +2757,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -2751,6 +2801,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2780,6 +2831,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const outgoingImplementationPrompt = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: implementationPrompt, + }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || @@ -2816,7 +2872,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -2874,6 +2930,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2910,7 +2967,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { + (effort: ProviderReasoningEffort) => { setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, @@ -3342,474 +3399,505 @@ export default function ChatView({ threadId }: ChatViewProps) { data-chat-composer-form="true" >
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} - - {/* Textarea area */}
- {composerMenuOpen && !isComposerApprovalState && ( -
- +
- )} + ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {/* Textarea area */} +
+ {isClaudeUltrathink ? ( +
+ + Ultrathink + +
+ ) : null} + {composerMenuOpen && !isComposerApprovalState && ( +
+
)} - -
- {/* Bottom toolbar */} - {activePendingApproval ? ( -
- 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} +
- ) : ( -
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : (
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - + {/* Provider/model picker */} + - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : null} - - - - - + ) : ( + <> + {selectedEffort != null ? ( + <> + + + + ) : null} - - - {activePlan || activeProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
+ - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( + + + + + + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )} +
+ + {/* Right side: send / stop button */} +
+ {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} - ) : ( -
+
+ ) : phase === "running" ? ( + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( - - - } + ) : ( +
+ + + + } > - Implement in new thread - - - -
+ +
+ + void onImplementPlanInNewThread()} + > + Implement in new thread + + +
+
+ ) + ) : ( + ) - ) : ( - - ) - ) : null} + ) : null} +
-
- )} + )} +
diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 6c72f497b..529f2769e 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,4 +1,4 @@ -import { type CodexReasoningEffort } from "@t3tools/contracts"; +import { type ProviderKind, type ProviderReasoningEffort } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; @@ -13,24 +13,40 @@ import { MenuTrigger, } from "../ui/menu"; -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { +function effortLabel(provider: ProviderKind, effort: ProviderReasoningEffort): string { + if (provider === "codex") { + const codexLabels: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + return codexLabels[effort] ?? effort; + } + + const claudeLabels: Record = { low: "Low", medium: "Medium", high: "High", - xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; + return claudeLabels[effort] ?? effort; +} + +export const ProviderTraitsPicker = memo(function ProviderTraitsPicker(props: { + provider: ProviderKind; + effort: ProviderReasoningEffort; + fastModeEnabled?: boolean; + options: ReadonlyArray; + onEffortChange: (effort: ProviderReasoningEffort) => void; + onFastModeChange?: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort(props.provider); const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), + effortLabel(props.provider, props.effort), + ...(props.provider === "codex" && props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) .join(" · "); @@ -56,7 +72,9 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { -
Reasoning
+
+ {props.provider === "codex" ? "Reasoning" : "Effort"} +
{ @@ -68,25 +86,29 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { > {props.options.map((effort) => ( - {reasoningLabelByOption[effort]} + {effortLabel(props.provider, effort)} {effort === defaultReasoningEffort ? " (default)" : ""} ))}
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.provider === "codex" && props.onFastModeChange ? ( + <> + + +
Fast Mode
+ { + props.onFastModeChange?.(value === "on"); + }} + > + off + on + +
+ + ) : null}
); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01..6bca85efe 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,8 +1,8 @@ import { - type CodexReasoningEffort, type ProviderKind, RuntimeMode, ProviderInteractionMode, + type ProviderReasoningEffort, } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; @@ -24,22 +24,24 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls interactionMode: ProviderInteractionMode; planSidebarOpen: boolean; runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; + selectedEffort: ProviderReasoningEffort | null; selectedProvider: ProviderKind; selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: ProviderReasoningEffort) => void; onCodexFastModeChange: (enabled: boolean) => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onToggleRuntimeMode: () => void; }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { + const defaultReasoningEffort = getDefaultReasoningEffort(props.selectedProvider); + const reasoningLabelByOption: Record = { low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; return ( @@ -57,10 +59,12 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls